From cd0329020a13e6f748b5eaecd51a50f83899e3e7 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 15:16:34 +0200 Subject: [PATCH 01/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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 2ac3d7a807275d159691a3735cd76fba61c603b5 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 30 May 2026 12:24:08 +0200 Subject: [PATCH 26/47] feat: allow modules to provide import aliases; ship a copy of dkjson to provide "json" if luajson isnt installed --- DependencyControl.json | 44 +- README.md | 34 +- modules/DependencyControl.moon | 24 +- .../DependencyControl/DownloadManager.moon | 17 +- modules/DependencyControl/Lock.moon | 2 +- modules/DependencyControl/ModuleProvider.moon | 58 ++ modules/DependencyControl/Record.moon | 11 +- modules/DependencyControl/TerribleMutex.moon | 26 +- modules/DependencyControl/Tests.moon | 77 +- modules/DependencyControl/UpdateFeed.moon | 2 +- modules/DependencyControl/Updater.moon | 2 +- modules/dkjson.moon | 28 + modules/dkjson/vendor/dkjson.lua | 810 ++++++++++++++++++ 13 files changed, 1061 insertions(+), 74 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..9c75da7 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -148,21 +148,6 @@ "moduleName": "requireffi.requireffi", "version": "0.1.1", "feed": "@{feed:ffi-experiments}" - }, - { - "moduleName": "DM.DownloadManager", - "version": "0.3.1", - "feed": "@{feed:ffi-experiments}" - }, - { - "moduleName": "BM.BadMutex", - "version": "0.1.3", - "feed": "@{feed:ffi-experiments}" - }, - { - "moduleName": "PT.PreciseTimer", - "version": "0.1.5", - "feed": "@{feed:ffi-experiments}" } ] } @@ -237,6 +222,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..25cbc3b 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,13 @@ __Features__: __Requirements__: * Aegisub > 3.2.0 (e.g. [Plorkyeran's](http://plorkyeran.com/aegisub/) r8792+ or [my](http://files.line0.eu/builds/Aegisub/) git builds) - * [LuaJSON](https://github.com/harningt/luajson) - * [DownloadManager](https://github.com/torque/ffi-experiments/releases) v0.3.0 - * [BadMutex](https://github.com/torque/ffi-experiments/releases) v0.1.2 - * [PreciseTimer](https://github.com/torque/ffi-experiments/releases) v0.1.4 + +DependencyControl is self-contained: it bundles a JSON library (dkjson) and ships pure-FFI +implementations of the timer, mutex and download manager it needs, so no extra modules have to be +installed. If you do have the native [ffi-experiments](https://github.com/torque/ffi-experiments) +modules ([DownloadManager](https://github.com/torque/ffi-experiments/releases), +[BadMutex](https://github.com/torque/ffi-experiments/releases)) or another `json` module installed, +those are used in preference to the bundled fallbacks automatically. ---------------------------------- @@ -184,6 +187,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..4381ceb 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -9,6 +9,25 @@ Update to a recent Aegisub build to resolve this issue. ]]\format MIN_MOONSCRIPT_VERSION, moonscript.version +-- Install the module-provides searcher and register DepCtrl's bundled fallbacks before +-- the sub-modules below load (they `require` the bare names "json", "BM.BadMutex" and +-- "DM.DownloadManager"). By default the searcher defers to a separately installed native +-- module and only falls back to ours; the optional env var forces ours by preempting the +-- module cache. +ModuleProvider = require "l0.DependencyControl.ModuleProvider" +ModuleProvider.install! + +provideBundled = (providerName, aliases, forceVar) -> + if forceVar and os.getenv(forceVar) == "1" + impl = require providerName + package.loaded[alias] = impl for alias in *aliases + else + ModuleProvider.register alias, providerName for alias in *aliases + +provideBundled "l0.dkjson", {"json", "dkjson"} +provideBundled "l0.DependencyControl.TerribleMutex", {"BM.BadMutex"}, "DEPCTRL_PREFER_FFI_MUTEX" +provideBundled "l0.DependencyControl.DownloadManager", {"DM.DownloadManager"}, "DEPCTRL_PREFER_FFI_DOWNLOADER" + Logger = require "l0.DependencyControl.Logger" UpdateFeed = require "l0.DependencyControl.UpdateFeed" ConfigHandler = require "l0.DependencyControl.ConfigHandler" @@ -36,8 +55,9 @@ 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", optional: true}, - {"BM.BadMutex", version: "0.1.3", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json", optional: true}, + -- BM.BadMutex and DM.DownloadManager are provided by DepCtrl's bundled FFI + -- implementations (see the provideBundled calls above); the native libraries are + -- preferred automatically when separately installed. {"requireffi.requireffi", version: "0.1.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json", optional: true}, } } diff --git a/modules/DependencyControl/DownloadManager.moon b/modules/DependencyControl/DownloadManager.moon index a3f6e87..971ca77 100644 --- a/modules/DependencyControl/DownloadManager.moon +++ b/modules/DependencyControl/DownloadManager.moon @@ -1,14 +1,8 @@ --- 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 +-- DM.DownloadManager-compatible download manager: a class that wraps DepCtrl's own +-- Downloader engine to replicate the native DM.DownloadManager API. +-- DependencyControl registers it as a provider for the "DM.DownloadManager" alias (see +-- ModuleProvider), so it's used wherever the native library isn't installed; native takes +-- precedence by default, and DEPCTRL_PREFER_FFI_DOWNLOADER=1 forces this implementation. Downloader = require "l0.DependencyControl.Downloader" FileOps = require "l0.DependencyControl.FileOps" @@ -92,5 +86,4 @@ class DownloadManager return true if actual == expected\lower! false, msgs.hashMismatch\format actual, expected -package.loaded["DM.DownloadManager"] = DownloadManager return DownloadManager diff --git a/modules/DependencyControl/Lock.moon b/modules/DependencyControl/Lock.moon index 94da0bb..40d56de 100644 --- a/modules/DependencyControl/Lock.moon +++ b/modules/DependencyControl/Lock.moon @@ -1,4 +1,4 @@ -mutex = require "l0.DependencyControl.TerribleMutex" +mutex = require "BM.BadMutex" Timer = require "l0.DependencyControl.Timer" Logger = require "l0.DependencyControl.Logger" Enum = require "l0.DependencyControl.Enum" 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/TerribleMutex.moon b/modules/DependencyControl/TerribleMutex.moon index 25edc80..496ffe3 100644 --- a/modules/DependencyControl/TerribleMutex.moon +++ b/modules/DependencyControl/TerribleMutex.moon @@ -1,21 +1,14 @@ --- Process-scoped mutex using native OS synchronization primitives. +-- Process-scoped mutex using native OS synchronization primitives — a pure-FFI +-- stand-in for BM.BadMutex. DependencyControl registers it as a provider for the +-- "BM.BadMutex" alias (see ModuleProvider), so it is used wherever the native +-- library isn't installed; native takes precedence by default, and +-- DEPCTRL_PREFER_FFI_MUTEX=1 forces this implementation instead. -- --- 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. +-- The mutex name embeds the process ID so concurrent Aegisub / test-launcher +-- instances never share the same lock. 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" @@ -77,11 +70,8 @@ else 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. + -- mirrors the BM.BadMutex version this stands in for 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 e122bd1..97654ea 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -19,8 +19,9 @@ 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" + BADMUTEX_MODULE_NAME = "BM.BadMutex" TIMER_MODULE_NAME = "l0.DependencyControl.Timer" FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" JSON_MODULE_NAME = "json" @@ -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." @@ -134,7 +138,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- BM.BadMutex alias registered_asBadMutex: (ut) -> - -- TerribleMutex registers itself (or native BM.BadMutex) under this name + -- DepCtrl provides "BM.BadMutex" (native if installed, else this FFI mutex), + -- so the bare name resolves once DepCtrl is loaded ut\assertNotNil package.loaded["BM.BadMutex"] _order: { @@ -180,6 +185,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.)" @@ -1007,8 +1048,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> ut\assertEquals lock\getState!, Lock.LockState.Unknown getState_held: (ut) -> - (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true - ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + (ut\stub 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! @@ -1018,8 +1059,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- lock lock_success: (ut) -> - tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true - ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + 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! @@ -1029,8 +1070,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock\release! lock_alreadyHeld: (ut) -> - tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true - ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + 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 @@ -1040,7 +1081,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock\release! lock_timeout: (ut) -> - tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns false + tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns false sleepStub = ut\stub TIMER_MODULE_NAME, "sleep" ut\stub Lock.logger, "trace" lock = Lock namespace: "ns", resource: "res" @@ -1051,11 +1092,11 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock_retry: (ut) -> callCount = 0 - tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\calls -> + tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\calls -> callCount += 1 callCount >= 2 -- fails first, succeeds second sleepStub = ut\stub TIMER_MODULE_NAME, "sleep" - ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + ut\stub BADMUTEX_MODULE_NAME, "unlock" ut\stub Lock.logger, "trace" lock = Lock namespace: "ns", resource: "res" state, timePassed = lock\lock! @@ -1067,8 +1108,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- tryLock tryLock_success: (ut) -> - tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true - ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + 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! @@ -1077,7 +1118,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock\release! tryLock_fail: (ut) -> - tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns false + tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns false ut\stub TIMER_MODULE_NAME, "sleep" ut\stub Lock.logger, "trace" lock = Lock namespace: "ns", resource: "res" @@ -1088,8 +1129,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- release release_held: (ut) -> - (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true - unlockStub = ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + (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! @@ -1109,8 +1150,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- GC canary: unreleased lock is cleaned up and warns on collection gc_canary: (ut) -> - (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true - unlockStub = ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + (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 diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index d8d79ac..0bd73f5 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -1,5 +1,5 @@ json = require "json" -DownloadManager = require "l0.DependencyControl.DownloadManager" +DownloadManager = require "DM.DownloadManager" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index 59a12c3..5d6329a 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -1,5 +1,5 @@ lfs = require "lfs" -DownloadManager = require "l0.DependencyControl.DownloadManager" +DownloadManager = require "DM.DownloadManager" Timer = require "l0.DependencyControl.Timer" UpdateFeed = require "l0.DependencyControl.UpdateFeed" fileOps = require "l0.DependencyControl.FileOps" 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 From 5afefe294d18a58d272ee02605d927dcc65972f8 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 31 May 2026 21:26:10 +0200 Subject: [PATCH 27/47] feat: CLI/CI test runner (WIP) --- .github/workflows/test.yml | 50 ++++++ .gitignore | 1 + README.md | 52 +++++++ modules/AegisubShims.moon | 8 +- modules/AegisubShims/aegisub.moon | 142 ++++++++++++++++++ modules/DependencyControl.moon | 4 +- modules/DependencyControl/FileOps.moon | 47 ++++-- modules/DependencyControl/Logger.moon | 8 +- modules/DependencyControl/ModuleProvider.moon | 8 +- modules/DependencyControl/Record.moon | 2 +- modules/DependencyControl/Tests.moon | 22 +-- modules/DependencyControl/UnitTestSuite.moon | 85 +++++++++++ run-tests.lua | 121 +++++++++++++++ 13 files changed, 518 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 modules/AegisubShims/aegisub.moon create mode 100644 run-tests.lua diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8162dda --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +name: Tests + +on: + pull_request: + branches: + - 'master' + workflow_dispatch: + inputs: + ref: + description: 'Release tag or branch to scan.' + required: true + default: 'master' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.ref }} + + - uses: leafo/gh-actions-lua@v13 + with: + luaVersion: "luajit-2.1.0-beta3" + # DepCtrl relies on Lua 5.2 features (table.unpack, __pairs/__len) + luaCompileFlags: "XCFLAGS=-DLUAJIT_ENABLE_LUA52COMPAT" + + - uses: leafo/gh-actions-luarocks@v6 + - run: luarocks install moonscript + - run: luarocks install luafilesystem + + - name: Run tests + run: lua run-tests.lua + + - name: Publish test report + uses: ctrf-io/github-test-reporter@v1 + if: ${{ !cancelled() }} + with: + report-path: ./ctrf/*.json + pull-request: ${{ github.event_name == 'pull_request' && true || false }} + overwrite-comment: ${{ github.event_name == 'pull_request' && true || false }} + annotate: false + use-suite-name: true + status-check: true + summary-report: true + failed-report: true + upload-artifact: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82085e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/ctrf/*.json diff --git a/README.md b/README.md index 25cbc3b..3547254 100644 --- a/README.md +++ b/README.md @@ -601,3 +601,55 @@ Reference documentation for the UnitTestSuite module is available in the [source #### UpdateFeed #### tbd + +---------------------------------- + +### Running the Test Suite ### + +DependencyControl ships a headless test runner (`run-tests.lua`) that executes the full unit +test suite from the command line — locally or in CI — **without** an Aegisub process. An +`aegisub` global shim and bundled FFI implementations of the timer, mutex and download manager +stand in for the host application, and JSON is vendored (dkjson), so the only external +dependencies are LuaJIT and two LuaRocks modules. + +#### Prerequisites #### + + * _LuaJIT_ on your `PATH`, built with `DLUAJIT_ENABLE_LUA52COMPAT` + * _LuaRocks_, configured for Lua v5.1, which _LuaJIT_ is ABI-compatible with. You may have to select the Lua version explicitly via `luarocks --lua-version=5.1` + * The [moonscript](https://luarocks.org/modules/leafo/moonscript) and [luafilesystem](https://luarocks.org/modules/hisham/luafilesystem) rocks, installed into that 5.1 tree: + + ```sh + luarocks --lua-version=5.1 install moonscript + luarocks --lua-version=5.1 install luafilesystem + ``` + + * Your `LUA_PATH` / `LUA_CPATH` must let `luajit` find the LuaRocks-installed modules (`luarocks --lua-version=5.1 path --bin` prints the correct values). + +#### Running #### + +From the repository root: + +```sh +luajit run-tests.lua +``` + +The runner resolves its own location to find the bundled `modules/` directory, so it works +regardless of the current working directory. It exits `0` when every test passes and `1` otherwise. + +Log files and config/feed caches are written to a per-run throwaway workspace created under +your system temp directory, rather than touching your real Aegisub configuration. Each Aegisub +path token (`?user`, `?temp`, ...) gets its own subdirectory there. Set `DEPCTRL_TEMP_DIR` to +choose a different base directory for that workspace. + +#### Test report #### + +After running, the suite writes a [CTRF](https://ctrf.io) report — a JSON test-result format +understood by ready-made CI reporters. By default it lands at `./ctrf/DependencyControl.json` +relative to the runner/repository root; pass a different path as the first CLI argument: + +```sh +luajit run-tests.lua path/to/report.json +``` + +The same report can be produced programmatically from any `UnitTestSuite` via +`suite\writeResults(path)` (or `suite\toCtrf!` for the raw table). diff --git a/modules/AegisubShims.moon b/modules/AegisubShims.moon index 5ee0ce5..1f9b437 100644 --- a/modules/AegisubShims.moon +++ b/modules/AegisubShims.moon @@ -1,3 +1,9 @@ aegisub = require "l0.AegisubShims.aegisub" -return {:aegisub} +-- Re-expose the shim's configuration hooks (see AegisubShims.aegisub) so callers can +-- relocate path tokens without reaching into the faux `aegisub` global. +return { + :aegisub + setPathToken: aegisub.__depctrl.setPathToken + getPathToken: aegisub.__depctrl.getPathToken +} diff --git a/modules/AegisubShims/aegisub.moon b/modules/AegisubShims/aegisub.moon new file mode 100644 index 0000000..b2fcc99 --- /dev/null +++ b/modules/AegisubShims/aegisub.moon @@ -0,0 +1,142 @@ +-- Headless shim for the Aegisub automation Lua API. +-- Installs `aegisub` as a global before any module that requires Aegisub-specific APIs. +-- +-- Configurable via environment variables: +-- DEPCTRL_USER_DIR — base for ?user / ?local (default: %APPDATA%\Aegisub / ~/.aegisub) +-- DEPCTRL_DATA_DIR — base for ?data (default: same as ?user; real Aegisub uses exe dir) +-- DEPCTRL_TEMP_DIR — base for ?temp (default: %TEMP% / /tmp) + +ffi = require "ffi" + +isWindows = ffi.os == "Windows" +pathSep = isWindows and "\\" or "/" + +tempDir = os.getenv("DEPCTRL_TEMP_DIR") or (isWindows and (os.getenv("TEMP")) or "/tmp") +userDir = os.getenv("DEPCTRL_USER_DIR") or + (isWindows and "#{os.getenv 'APPDATA'}\\Aegisub" or "#{os.getenv 'HOME'}/.aegisub") +dataDir = os.getenv("DEPCTRL_DATA_DIR") or userDir + +-- Canonical token table matching libaegisub/path.cpp. +-- Empty string means "unset" — decode_path returns the path unchanged (same as real Aegisub). +-- ?audio, ?script, ?video are empty because no file is loaded headlessly. +pathTokens = { + "?audio": "" + "?data": dataDir + "?dictionary": dataDir .. pathSep .. "dictionaries" + "?local": userDir + "?script": "" + "?temp": tempDir + "?user": userDir + "?video": "" +} + +-- Sorted longest-first so ?dictionary matches before ?data. Rebuilt whenever a token +-- changes; decodePath closes over the `sortedTokens` upvalue, so reassigning it here is +-- enough to update the resolver. +local sortedTokens +rebuildSortedTokens = -> + sortedTokens = [{spec, dir} for spec, dir in pairs pathTokens] + table.sort sortedTokens, (a, b) -> #a[1] > #b[1] +rebuildSortedTokens! + +-- Normalize a token name to its canonical "?name" form so callers may pass either +-- "user" or "?user". +normalizeToken = (spec) -> + "string" == type(spec) and (spec\sub(1, 1) == "?" and spec or "?#{spec}") or spec + +--- Points an Aegisub path token (e.g. "?user", "?temp") at a different directory. +-- Lets headless callers relocate where DepCtrl reads/writes without environment variables. +-- @param spec string the token to set, with or without the leading "?" ("user" or "?user") +-- @param dir string|nil the directory to resolve the token to; nil/"" marks it unset +-- @return string|nil dir the value the token now resolves to +setPathToken = (spec, dir) -> + pathTokens[normalizeToken spec] = dir or "" + rebuildSortedTokens! + return dir + +--- Returns the directory an Aegisub path token currently resolves to. +-- @param spec string the token to query, with or without the leading "?" +-- @return string|nil dir the configured directory, or nil if the token is unknown +getPathToken = (spec) -> + dir = pathTokens[normalizeToken spec] + return dir if dir and dir != "" + +decodePath = (path) -> + for {spec, dir} in *sortedTokens + if path\sub(1, #spec) == spec + -- Empty dir means token is unset — return path as-is (Aegisub behavior). + return path if dir == "" + suffix = path\sub #spec + 1 + -- Consume the separator that follows the token, if any. + suffix = suffix\sub 2 if suffix\sub(1, 1) == "/" or suffix\sub(1, 1) == "\\" + return suffix == "" and dir or dir .. pathSep .. suffix + return path -- no token: return as-is + +aegisub = { + lua_automation_version: 4 + + decode_path: decodePath + + -- Always-nil stubs for context-dependent queries. + frame_from_ms: -> nil -- nil when no video loaded + ms_from_frame: -> nil + video_size: -> nil + keyframes: -> nil + get_audio_selection: -> nil + project_properties: -> nil + file_name: -> nil + + -- No-ops. + register_macro: -> nil + register_filter: -> nil + set_undo_point: -> nil + set_status_text: -> nil + + -- text_extents needs font rendering; error loudly rather than returning garbage. + text_extents: -> error "aegisub.text_extents is not available in headless mode", 2 + + gettext: (s) -> s + + cancel: -> error "aegisub.cancel", 2 + + -- These are normally injected by LuaProgressSink during macro execution. + -- We provide static stubs so scripts that call them at module load time don't crash. + log: (level, msg, ...) -> + text = type(level) == "string" and level or msg + io.stderr\write tostring(text or "") .. "\n" + + debug: { + out: (level, msg, ...) -> + text = type(level) == "string" and level or msg + io.stderr\write tostring(text or "") .. "\n" + } + + progress: { + set: -> nil + task: -> nil + title: -> nil + is_cancelled: -> false + } + + dialog: { + display: -> {}, false + open: -> nil + save: -> nil + } + + clipboard: { + get: -> "" + set: -> true + } +} + +-- Shim-only configuration hooks, namespaced so they can't collide with the real +-- Aegisub API surface. Surfaced through l0.AegisubShims for callers to use. +aegisub.__depctrl = { + :setPathToken + :getPathToken +} + +_G.aegisub = aegisub + +return aegisub diff --git a/modules/DependencyControl.moon b/modules/DependencyControl.moon index 4381ceb..71f6b40 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -15,14 +15,14 @@ Update to a recent Aegisub build to resolve this issue. -- module and only falls back to ours; the optional env var forces ours by preempting the -- module cache. ModuleProvider = require "l0.DependencyControl.ModuleProvider" -ModuleProvider.install! +ModuleProvider\install! provideBundled = (providerName, aliases, forceVar) -> if forceVar and os.getenv(forceVar) == "1" impl = require providerName package.loaded[alias] = impl for alias in *aliases else - ModuleProvider.register alias, providerName for alias in *aliases + ModuleProvider\register alias, providerName for alias in *aliases provideBundled "l0.dkjson", {"json", "dkjson"} provideBundled "l0.DependencyControl.TerribleMutex", {"BM.BadMutex"}, "DEPCTRL_PREFER_FFI_MUTEX" diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index e4f6aa0..fedae61 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -4,6 +4,11 @@ Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" Crypto = require "l0.DependencyControl.Crypto" Enum = require "l0.DependencyControl.Enum" + +ENOENT = 2 -- POSIX error code for "No such file or directory" +ENOTDIR = 20 -- POSIX error code for "Not a directory" +ERROR_PATH_NOT_FOUND = 3 -- Windows error code for "The system cannot find the path specified" + local ConfigView --- Filesystem utility helpers used by DependencyControl. @@ -365,7 +370,7 @@ class FileOps if recurse -- recursively remove contained files and directories - toRemove = ["#{path}/#{file}" for file in lfs.dir path] + toRemove = [FileOps.joinPath(path, file) for file in lfs.dir path] res, details = FileOps.remove toRemove, true unless res fileList = table.concat ["#{path}: #{res[2]}" for path, res in pairs details when not res[1]], "\n" @@ -378,18 +383,39 @@ class FileOps return true + -- Creates `dir` along with any missing parent directories, building the path up one + -- segment at a time. Idempotent: levels that already exist are left untouched. + -- @param dir string a validated, absolute directory path + -- @return boolean|nil true on success, or nil on error + -- @return string dirPathOrError the directory path on success, or an error message + mkdirRecursive = (dir) -> + -- preserve a leading separator so POSIX absolute paths keep their root + accum, first = dir\match("^[/\\]") and FileOps.pathSep or "", true + for segment in FileOps.pathSegments dir + accum = first and accum .. segment or "#{accum}#{FileOps.pathSep}#{segment}" + first = false + continue if accum\match "^%a:$" -- skip bare drive letters like "C:" + unless lfs.attributes accum, "mode" + _, err = lfs.mkdir accum + -- tolerate races and pre-existing levels; only fail if it's still absent + if err and not lfs.attributes accum, "mode" + return nil, msgs.mkdir.createError\format err + return true, dir + --- 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) + -- @param[opt=false] recurse boolean whether to also create any missing parent directories -- @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) -> + mkdir: (path, isFile, recurse) -> mode, fullPath, dev, dir, file = FileOps.attributes path, "mode" dir = isFile and table.concat({dev,dir or file}) or fullPath if mode == nil return nil, msgs.attributes.genericError\format fullPath elseif not mode + return mkdirRecursive dir if recurse res, err = lfs.mkdir dir if err -- can't create directory (possibly a permission error) return nil, msgs.mkdir.createError\format err @@ -411,18 +437,21 @@ class FileOps attributes: (path, key) -> fullPath, dev, dir, file = FileOps.validateFullPath path unless fullPath - path = "#{lfs.currentdir!}/#{path}" + path = FileOps.joinPath lfs.currentdir!, path fullPath, dev, dir, file = FileOps.validateFullPath path unless fullPath return nil, msgs.attributes.badPath\format dev - attr, err = lfs.attributes fullPath, key - if err - return nil, msgs.attributes.genericError\format err - elseif not attr + attr, err, errCode = lfs.attributes fullPath, key + if attr + return attr, fullPath, dev, dir, file + -- Aegisub's lfs implementation signals a non-existent file/dir with a bare nil, + -- while the stock library (https://lunarmodules.github.io/luafilesystem/; v1.7.0+) + -- returns an error code alongside an error message + elseif err == nil or errCode == ENOENT or errCode == ERROR_PATH_NOT_FOUND or errCode == ENOTDIR return false, fullPath, dev, dir, file - - return attr, fullPath, dev, dir, file + else + return nil, msgs.attributes.genericError\format err --- Validates and normalizes an absolute filesystem path. -- @param path string|string[] Either a path or an array of path segments diff --git a/modules/DependencyControl/Logger.moon b/modules/DependencyControl/Logger.moon index 96038d4..e98a32a 100644 --- a/modules/DependencyControl/Logger.moon +++ b/modules/DependencyControl/Logger.moon @@ -205,12 +205,12 @@ class Logger files, totalSize, deletedSize, now, f = {}, 0, 0, os.time!, 0 dir = aegisub.decode_path @logDir - lfs.chdir dir for file in lfs.dir dir - attr = lfs.attributes file + fullPath = "#{dir}/#{file}" + attr = lfs.attributes fullPath if type(attr) == "table" and attr.mode == "file" and file\find @fileMatch f += 1 - files[f] = {name:file, modified:attr.modification, size:attr.size} + files[f] = {name: file, path: fullPath, modified: attr.modification, size: attr.size} table.sort files, (a,b) -> a.modified > b.modified total, kept = #files, 0 @@ -219,7 +219,7 @@ class Logger totalSize += file.size if doWipe or kept > maxFiles or totalSize > maxSize or file.modified+maxAge < now deletedSize += file.size - os.remove file.name + os.remove file.path else kept += 1 return total-kept, deletedSize, total, totalSize diff --git a/modules/DependencyControl/ModuleProvider.moon b/modules/DependencyControl/ModuleProvider.moon index e47ea2e..206c44f 100644 --- a/modules/DependencyControl/ModuleProvider.moon +++ b/modules/DependencyControl/ModuleProvider.moon @@ -29,7 +29,7 @@ class ModuleProvider -- @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) -> + @register = (alias, providerName) => return false unless type(alias) == "string" and type(providerName) == "string" return false if state.providers[alias] state.providers[alias] = providerName @@ -37,7 +37,7 @@ class ModuleProvider --- Registers every alias declared in a record's `provides` field. -- @param record table a record with .moduleName and an optional .provides array - @registerRecord = (record) -> + @registerRecord = (record) => return unless record.provides and record.moduleName for alias in *record.provides name = type(alias) == "table" and alias.name or alias @@ -46,10 +46,10 @@ class ModuleProvider --- 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] + @getProvider = (alias) => state.providers[alias] --- Installs the alias searcher. Idempotent across reloads. - @install = -> + @install = => return if state.installed loaders = package.loaders or package.searchers loaders[#loaders + 1] = search diff --git a/modules/DependencyControl/Record.moon b/modules/DependencyControl/Record.moon index bcf61b8..62986e7 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -132,7 +132,7 @@ class Record extends Common -- `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 @ + ModuleProvider\registerRecord @ shouldWriteConfig = @loadConfig! diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon index 97654ea..9891ee7 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -190,27 +190,27 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> register_andGetProvider: (ut) -> name = uniqueName "alias" - ut\assertTrue ModuleProvider.register name, "some.provider" - ut\assertEquals ModuleProvider.getProvider(name), "some.provider" + 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" + 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" + 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 + ModuleProvider\install! -- idempotent; already installed during load name = uniqueName "aliasToSemver" - ModuleProvider.register name, "l0.DependencyControl.SemanticVersioning" + 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 diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon index 8eb63ee..2055cc0 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/DependencyControl/UnitTestSuite.moon @@ -83,9 +83,12 @@ class UnitTest -- @treturn[2] string the error message describing how the test failed run: (...) => @assertFailed = false + @ran = true @stubs = {} @logStart! + startTime = os.clock! @success, res = xpcall @f, debug.traceback, @, ... + @duration = os.clock! - startTime for i = #@stubs, 1, -1 @stubs[i]\restore! @logResult res @@ -539,9 +542,12 @@ class UnitTestSetup extends UnitTest -- @treturn[2] boolean false (test failed) -- @treturn[2] string the error message describing how the test failed run: => + @ran = true @logger\logEx nil, @@msgs.run.setup, false + startTime = os.clock! res = table.pack pcall @f, @ + @duration = os.clock! - startTime @success = table.remove res, 1 @logResult res[1] @@ -736,6 +742,7 @@ class UnitTestSuite @logger\log msgs.run.running, classCnt, @namespace @logger.indent += 1 + @startTime = os.time! * 1000 -- epoch ms, for the CTRF report summary for i, cls in pairs classes success, failed = cls\run abortOnFail unless success @@ -746,6 +753,7 @@ class UnitTestSuite @logger\warn msgs.run.abort, i return false, allFailed + @endTime = os.time! * 1000 @logger.indent -= 1 @success = failedCnt == 0 if @success @@ -753,3 +761,80 @@ class UnitTestSuite else @logger\log msgs.run.classesFailed, failedCnt, classCnt return @success, failedCnt > 0 and allFailed or nil + + --- Collects the results of the most recent run into a flat, format-agnostic structure. + -- Only tests that actually ran are included; a failed class setup surfaces as an errored + -- "setup" case so skipped classes still show up in the report. + -- @local + -- @treturn {{name=string, cases={{name, classname, duration, failure?, error?}, ...}}, ...} + collectResults: => + suites = {} + for cls in *@classes + cases = {} + -- a setup failure aborts the whole class; represent it as an error case + if cls.setup and cls.setup.ran and not cls.setup.success + cases[#cases+1] = { name: "setup", classname: cls.name, + duration: cls.setup.duration or 0, error: cls.setup.errMsg } + for test in *cls.tests + continue unless test.ran + cases[#cases+1] = { + name: test.name, classname: cls.name, duration: test.duration or 0 + -- keep assertion failures and unexpected errors separate for consumers + -- that care; CTRF itself folds both into a single "failed" status + failure: not test.success and test.assertFailed and (test.errMsg or "assertion failed") or nil + error: not test.success and not test.assertFailed and (test.errMsg or "unexpected error") or nil + } + suites[#suites+1] = { name: cls.name, :cases } + return suites + + --- Builds a CTRF (Common Test Report Format) report of the most recent run. + -- CTRF is a JSON test report schema understood by ready-made CI reporters + -- (e.g. the ctrf-io/github-test-reporter action). See https://ctrf.io. + -- @treturn table the CTRF report as a plain Lua table, ready to be JSON-encoded + toCtrf: => + tests, passed, failed = {}, 0, 0 + for suite in *@collectResults! + for c in *suite.cases + failureMsg = c.failure or c.error -- CTRF has a single "failed" status + if failureMsg then failed += 1 else passed += 1 + entry = { + name: c.name + suite: c.classname + status: failureMsg and "failed" or "passed" + duration: math.floor c.duration * 1000 + 0.5 -- seconds -> ms + } + entry.message = failureMsg if failureMsg + tests[#tests+1] = entry + + return { + results: { + tool: { name: "DependencyControl.UnitTestSuite" } + summary: { + tests: passed + failed + :passed, :failed + pending: 0, skipped: 0, other: 0 + start: @startTime or 0 + stop: @endTime or 0 + } + :tests + } + } + + --- Writes a CTRF JSON report of the most recent run to the given path. + -- Any missing parent directories are created; Aegisub path tokens are expanded. + -- @tparam string path destination file path + -- @treturn[1] boolean true on success + -- @treturn[2] nil + -- @treturn[2] string an error message + writeResults: (path) => + FileOps = require "#{Common.moduleName}.FileOps" + json = require "json" -- provided by DepCtrl (bundled dkjson) once it's loaded + + dirRes, err = FileOps.mkdir path, true, true + return nil, err if dirRes == nil + + handle, msg = io.open aegisub.decode_path(path), "wb" + return nil, msg unless handle + handle\write json.encode @toCtrf!, { indent: true } + handle\close! + return true diff --git a/run-tests.lua b/run-tests.lua new file mode 100644 index 0000000..dccef1e --- /dev/null +++ b/run-tests.lua @@ -0,0 +1,121 @@ +#!/usr/bin/env luajit +-- Headless DependencyControl test runner. +-- +-- Runs the DepCtrl unit test suite from the command line (locally or in CI) without +-- an Aegisub process. Requires LuaJIT (built with LUA52COMPAT=1) plus the LuaRocks +-- modules `moonscript` and `luafilesystem`. JSON is vendored (dkjson), and the FFI +-- timer/mutex/downloader are bundled, so no other external modules are needed. +-- +-- luajit run-tests.lua [ctrf-report-path] +-- +-- An optional first argument sets where the CTRF test report is written; it +-- defaults to ctrf/DependencyControl.json next to this script. +-- Exit code is 0 when every test passes, 1 otherwise. + +local ffi = require "ffi" +local lfs = require "lfs" +require "moonscript" +local moonbase = require "moonscript.base" + +-- normally provided by the hosting macro in Aegisub environments +script_namespace = "DepCtrl.Tests" + +local isWindows = ffi.os == "Windows" +local pathSep = isWindows and "\\" or "/" + +-- Utility functions + +local function dirname(path) + return (path or ""):match("^(.*)[/\\][^/\\]*$") or "." +end + +local function isAbsolute(path) + return path:match("^%a:[/\\]") ~= nil -- "C:\..." + or path:match("^[/\\]") ~= nil -- "/..." or "\..." +end + +local function fileExists(path) + local f = io.open(path, "r") + if f then + f:close() + return true + end + return false +end + + +-- Resolve the repo root from this script's own location so the runner works regardless +-- of the current working directory. The path is made absolute up front so module +-- resolution can't be thrown off by anything that changes the process CWD mid-run. + +local testLauncherScriptDir = dirname(arg and arg[0]) +if testLauncherScriptDir == "." then + testLauncherScriptDir = lfs.currentdir() -- avoid a trailing "\." segment +elseif not isAbsolute(testLauncherScriptDir) then + testLauncherScriptDir = lfs.currentdir() .. pathSep .. testLauncherScriptDir +end +local modulesDir = testLauncherScriptDir .. pathSep .. "modules" + +-- Custom searcher mapping the `l0.` namespace onto the repo's `modules/` directory, +-- loading either MoonScript sources or plain Lua files. Appended last so it only fires +-- for our own modules; everything else (lfs, ffi, moonscript) resolves through the stock +-- searchers. The bare aliases "json", "BM.BadMutex" and "DM.DownloadManager" are routed +-- to their `l0.` providers by DepCtrl's own ModuleProvider searcher, which in turn lands +-- back here. +local function l0ModuleSearcher(name) + local moduleNameWithoutNamespace = name:match("^l0%.(.+)$") + if not moduleNameWithoutNamespace then return nil end + + local moduleRelativePath = moduleNameWithoutNamespace:gsub("%.", pathSep) + local basePath = modulesDir .. pathSep .. moduleRelativePath + local candidates = { + { path = basePath .. ".moon", moon = true }, + { path = basePath .. ".lua", moon = false }, + { path = basePath .. pathSep .. "init.moon", moon = true }, + { path = basePath .. pathSep .. "init.lua", moon = false }, + } + for _, c in ipairs(candidates) do + if fileExists(c.path) then + local chunk, err = (c.moon and moonbase.loadfile or loadfile)(c.path) + if not chunk then error(err) end + return chunk + end + end + return "\n\tno l0 module file for '" .. name .. "' under " .. modulesDir +end +table.insert(package.loaders or package.searchers, l0ModuleSearcher) + +-- Install the Aegisub global shim. It exposes a small configuration API which we use to +-- point the path tokens at a throwaway workspace, so the suite's log files and feed caches +-- land somewhere writable instead of the user's real Aegisub config directory. +local shims = require "l0.AegisubShims" + +local workspace = shims.getPathToken("temp") .. pathSep .. ("depctrl-tests-%x"):format(os.time() % 0x100000) +for _, token in ipairs({ "user", "local", "data", "temp" }) do + shims.setPathToken(token, workspace .. pathSep .. token) +end + +-- Make sure the directories the shim now resolves actually exist. FileOps.mkdir with the +-- recurse flag creates any missing parents (validateFullPath expands the path tokens). +local FileOps = require "l0.DependencyControl.FileOps" +FileOps.mkdir("?temp", false, true) +FileOps.mkdir("?user/log", false, true) + +-- Load DepCtrl (triggers the ModuleProvider bootstrap for json/BadMutex/DownloadManager) +-- and its test suite, then run it. +local DepCtrl = require "l0.DependencyControl" +local suite = require "l0.DependencyControl.Tests" + +suite:import(DepCtrl) +local success = suite:run() + +-- Write CTRF test report +local reportPath = arg[1] or (testLauncherScriptDir .. pathSep .. "ctrf" .. pathSep .. "DependencyControl.json") +local wrote, writeErr = suite:writeResults(reportPath) + +io.stderr:write(wrote and ("\nWrote CTRF report to " .. reportPath .. "\n") + or ("\nWarning: couldn't write CTRF report: " .. tostring(writeErr) .. "\n")) + +io.stderr:write(success and "\nAll DependencyControl tests passed.\n" + or "\nDependencyControl tests FAILED.\n") +os.exit(success and 0 or 1) From c9d561a480486d1987765c8e082747f9ad915059 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 31 May 2026 21:41:55 +0200 Subject: [PATCH 28/47] fix: logger trying to clean log files when the log dir doesn't exist (yet) --- modules/DependencyControl/Logger.moon | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/DependencyControl/Logger.moon b/modules/DependencyControl/Logger.moon index e98a32a..a4ab943 100644 --- a/modules/DependencyControl/Logger.moon +++ b/modules/DependencyControl/Logger.moon @@ -205,6 +205,9 @@ class Logger files, totalSize, deletedSize, now, f = {}, 0, 0, os.time!, 0 dir = aegisub.decode_path @logDir + -- nothing to trim if the log directory hasn't been created yet + return 0, 0, 0, 0 unless lfs.attributes dir, "mode" + for file in lfs.dir dir fullPath = "#{dir}/#{file}" attr = lfs.attributes fullPath From 09f8db2e07b476174a973d1854149c2896ecd04e Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 31 May 2026 21:46:16 +0200 Subject: [PATCH 29/47] fix: broken home directory expansion on linux --- modules/DependencyControl/FileOps.moon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index fedae61..f0fb00d 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -470,7 +470,7 @@ class FileOps path = aegisub.decode_path path -- expand home directory on linux homeDir = os.getenv "HOME" - path = path\gsub "^~", "{#homeDir}/" if homeDir + path = path\gsub "^~", "#{homeDir}/" if homeDir -- use single native path separators path = path\gsub "[\\/]+", pathMatch.sep -- check length From dd545b31bc7af635007501ced07d496160d19ae8 Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 2 Jun 2026 01:38:54 +0200 Subject: [PATCH 30/47] fix: stall detection and concurrent connection limit for downloader --- .github/workflows/test.yml | 8 +- modules/DependencyControl/Downloader.moon | 212 +++++++++++--------- modules/DependencyControl/EventEmitter.moon | 38 ++++ 3 files changed, 162 insertions(+), 96 deletions(-) create mode 100644 modules/DependencyControl/EventEmitter.moon diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8162dda..5bb2421 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,10 +26,17 @@ jobs: luaCompileFlags: "XCFLAGS=-DLUAJIT_ENABLE_LUA52COMPAT" - uses: leafo/gh-actions-luarocks@v6 + # moonscript loader - run: luarocks install moonscript + # lfs (Aegisub provides an internal copy) - run: luarocks install luafilesystem + # mock HTTP server dependencies + - run: luarocks install luasocket + - run: luarocks install copas + - run: luarocks install pegasus - name: Run tests + timeout-minutes: 5 run: lua run-tests.lua - name: Publish test report @@ -47,4 +54,3 @@ jobs: upload-artifact: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/modules/DependencyControl/Downloader.moon b/modules/DependencyControl/Downloader.moon index a77d788..2faa9fd 100644 --- a/modules/DependencyControl/Downloader.moon +++ b/modules/DependencyControl/Downloader.moon @@ -1,22 +1,14 @@ --- 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. +-- Non-blocking download manager with SHA-1 verification. +-- Pure FFI implementation inspired by torque's DM.DownloadManager. -- -- 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" +EventEmitter = require "l0.DependencyControl.EventEmitter" msgs = { addMissingArgs: "Required arguments #1 (url) and #2 (outfile) had the wrong type. Expected string, got '%s' and '%s'." @@ -26,6 +18,7 @@ msgs = { readFailed: "Connection error while reading response." openUrlFailed: "Could not open URL '%s'." curlInit: "Failed to initialize curl." + stalled: "Download stalled: no data received for %d seconds." } -- Lifecycle state of a single download. @@ -36,6 +29,7 @@ DownloadStatus = Enum "DownloadStatus", { 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 @@ -79,18 +73,38 @@ computeProgress = (downloads) -> -- } multiplex = (manager, driver) -> downloads = manager.downloads - active = {} - for dl in *downloads + queue = downloads + maxConnections = manager.maxConnections or 8 + stallTimeout = manager.stallTimeout + + active, queueIndex = {}, 1 + + -- Start the next queued download into an active slot. Returns the started download, or nil + -- when the queue is exhausted. A download that fails to start is finalized and skipped so + -- the slot stays filled. + startNext = -> + return nil if queueIndex > #queue + dl = queue[queueIndex] + queueIndex += 1 dl.bytesReceived = 0 ok, err = driver.start dl - if ok - dl.status = DownloadStatus.Active - active[#active + 1] = dl - else + unless ok dl\_complete err or "failed to start download" + return startNext! + dl.status = DownloadStatus.Active + dl._lastProgressBytesReceived, dl._lastProgressAt = 0, os.time! + active[#active + 1] = dl + dl + + fillSlots = -> + while #active < maxConnections and queueIndex <= #queue and not manager.cancelled + break unless startNext! + + fillSlots! -- one pass per loop iteration steps every still-active transfer exactly once while #active > 0 and not manager.cancelled + now = os.time! remaining = {} for dl in *active if dl._cancelRequested @@ -100,7 +114,16 @@ multiplex = (manager, driver) -> status = driver.step dl if status == "more" dl\_notifyProgress! - remaining[#remaining + 1] = dl + if dl.bytesReceived > dl._lastProgressBytesReceived + -- progress made: reset the stall timer + dl._lastProgressBytesReceived, dl._lastProgressAt = dl.bytesReceived, now + remaining[#remaining + 1] = dl + elseif stallTimeout and stallTimeout > 0 and now - dl._lastProgressAt >= stallTimeout + driver.finish dl + dl\_complete msgs.stalled\format stallTimeout + else + -- no new bytes yet, but not stalled long enough to give up + remaining[#remaining + 1] = dl elseif status == "done" driver.finish dl dl\_complete! @@ -108,12 +131,17 @@ multiplex = (manager, driver) -> driver.finish dl dl\_complete status active = remaining + fillSlots! + -- report progress and allow cancellation between each round of steps break unless report manager, computeProgress downloads - -- finalize any survivors as cancelled (whole-downloader cancellation) + -- cancel remaining individual downloads if the whole downloader is cancelled for dl in *active driver.finish dl dl\_cancel! + for i = queueIndex, #queue + queue[i]\_cancel! + driver.shutdown! if driver.shutdown -- Platform backend selection: sets defaultRunner(manager) and isInternetConnected(). @@ -130,6 +158,7 @@ if ffi.os != "Windows" int curl_easy_getinfo(void* handle, int info, ...); const char* curl_easy_strerror(int errornum); void* curl_multi_init(void); + int curl_multi_setopt(void* multi, int option, long value); 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); @@ -153,16 +182,20 @@ if ffi.os != "Windows" 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 + CURLOPT_WRITEDATA = 10001 -- write the response data to the file passed as a pointer + CURLOPT_URL = 10002 -- set the URL to fetch + CURLOPT_USERAGENT = 10018 -- set the User-Agent header + CURLOPT_FOLLOWLOCATION = 52 -- follow HTTP redirects + CURLOPT_FAILONERROR = 45 -- treat HTTP 4xx/5xx responses as errors + CURLOPT_NOPROGRESS = 43 -- disable curl's built-in progress meter + CURLOPT_CONNECTTIMEOUT = 78 -- abort if connecting takes longer than the specified number of seconds + CURLOPT_LOW_SPEED_LIMIT = 19 -- abort if the transfer speed is below this (in bytes/sec) for too long (see LOW_SPEED_TIME) + CURLOPT_LOW_SPEED_TIME = 20 -- the time (in seconds) the transfer speed should be below the limit before aborting + CURLINFO_SIZE_DOWNLOAD = 0x300008 -- total bytes downloaded so far + CURLINFO_CONTENT_LENGTH_DOWNLOAD = 0x30000F -- total expected size of the download, or -1 if unknown + CURLMSG_DONE = 1 -- a transfer completed (with either success or error) + CURLMOPT_MAX_TOTAL_CONNECTIONS = 13 -- max simultaneous connections of any kind + CURLMOPT_MAX_HOST_CONNECTIONS = 7 -- max simultaneous connections to the same host -- libcurl's varargs expect a C long for integer options; a bare Lua number -- would be passed as a double, so cast explicitly. @@ -178,7 +211,11 @@ if ffi.os != "Windows" -- Unix uses curl's own multi scheduler rather than our round-robin loop. defaultRunner = (manager) -> downloads = manager.downloads + -- libcurl keeps excess transfers queued internally multi = curl.curl_multi_init! + maxConnections = manager.maxConnections or 8 + curl.curl_multi_setopt multi, CURLMOPT_MAX_HOST_CONNECTIONS, ffi.cast "long", maxConnections + curl.curl_multi_setopt multi, CURLMOPT_MAX_TOTAL_CONNECTIONS, ffi.cast "long", maxConnections handleMap = {} for dl in *downloads @@ -199,6 +236,10 @@ if ffi.os != "Windows" setLong handle, CURLOPT_FAILONERROR, 1 setLong handle, CURLOPT_NOPROGRESS, 1 setLong handle, CURLOPT_CONNECTTIMEOUT, 30 + -- abort a transfer that drops below 1 byte/sec for stallTimeout seconds + if manager.stallTimeout and manager.stallTimeout > 0 + setLong handle, CURLOPT_LOW_SPEED_LIMIT, 1 + setLong handle, CURLOPT_LOW_SPEED_TIME, manager.stallTimeout dl._handle, dl._file = handle, file dl.status = DownloadStatus.Active handleMap[key handle] = dl @@ -207,12 +248,12 @@ if ffi.os != "Windows" 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] + multiStackInfo = curl.curl_multi_info_read multi, pending + break if multiStackInfo == nil + continue unless multiStackInfo.msg == CURLMSG_DONE + dl = handleMap[key multiStackInfo.easy_handle] continue unless dl - res = m.data.result + res = multiStackInfo.data.result dl.bytesReceived = getDouble dl._handle, CURLINFO_SIZE_DOWNLOAD ffi.C.fclose dl._file curl.curl_multi_remove_handle multi, dl._handle @@ -270,6 +311,7 @@ else 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 InternetSetOptionW(void* hInternet, unsigned long option, void* buffer, unsigned long bufferLen); int HttpQueryInfoW(void* hRequest, unsigned long infoLevel, void* buffer, unsigned long* bufferLen, unsigned long* index); int InternetGetConnectedState(unsigned long* flags, unsigned long reserved); ]] @@ -284,12 +326,14 @@ else 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 + INTERNET_FLAG_RELOAD = 0x80000000 -- force a reload from the server even if the content is cached + INTERNET_FLAG_NO_CACHE_WRITE = 0x04000000 -- don't commit this download to the cache + INTERNET_OPTION_MAX_CONNS_PER_SERVER = 73 -- max simultaneous connections to the same HTTP/1.1 server + INTERNET_OPTION_MAX_CONNS_PER_1_0_SERVER = 74 -- max simultaneous connections to the same HTTP/1.0 server + HTTP_QUERY_STATUS_CODE = 19 -- HTTP response status code (e.g. 200) + HTTP_QUERY_CONTENT_LENGTH = 5 -- total expected size of the download, or -1 if unknown + HTTP_QUERY_FLAG_NUMBER = 0x20000000 -- return the queried information as a number instead of a string (e.g. for status code or content length) + CHUNK_SIZE = 16384 -- bytes to read for each running download per iteration of the scheduler loop (max WinINet buffer size) queryNumber = (request, info) -> out = ffi.new "unsigned long[1]" @@ -301,47 +345,53 @@ else 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 = -> + makeWinINetDriver = (maxConnectionsPerServer = 8) -> + do + -- Lift the Windows-default 2-connections-per-server cap so all queued transfers can run at once; + -- otherwise a 3rd concurrent InternetOpenUrlW to the same host blocks and times out. + optVal = ffi.new "unsigned long[1]", maxConnectionsPerServer + winInet.InternetSetOptionW nil, INTERNET_OPTION_MAX_CONNS_PER_SERVER, optVal, 4 + winInet.InternetSetOptionW nil, INTERNET_OPTION_MAX_CONNS_PER_1_0_SERVER, optVal, 4 session = winInet.InternetOpenW toWide("DependencyControl"), 0, nil, nil, 0 - buffer = ffi.new "char[?]", CHUNK + buffer = ffi.new "char[?]", CHUNK_SIZE 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 + outFileHandle, err = io.open dl.outfile, "wb" + return false, (err or msgs.failedToOpen\format dl.outfile) unless outFileHandle 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! + outFileHandle\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! + outFileHandle\close! return false, msgs.httpStatus\format status - dl._request, dl._out = request, out + dl._request, dl._outFileHandle = request, outFileHandle dl.totalBytes = queryNumber request, HTTP_QUERY_CONTENT_LENGTH true step: (dl) -> - return msgs.readFailed if 0 == winInet.InternetReadFile dl._request, buffer, CHUNK, read + return msgs.readFailed if 0 == winInet.InternetReadFile dl._request, buffer, CHUNK_SIZE, read n = tonumber read[0] return "done" if n == 0 - dl._out\write ffi.string buffer, n + dl._outFileHandle\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 + dl._outFileHandle\close! if dl._outFileHandle + dl._request, dl._outFileHandle = nil shutdown: -> winInet.InternetCloseHandle session } defaultRunner = (manager) -> - multiplex manager, makeWinINetDriver! + multiplex manager, makeWinINetDriver manager.maxConnections else defaultRunner = (manager) -> @@ -352,47 +402,6 @@ else 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 @@ -458,10 +467,23 @@ class Downloader extends EventEmitter -- with an injected driver. @multiplex = multiplex + -- Maximum simultaneous transfers (also applied as the per-server connection limit on each + -- backend). Excess downloads are queued and started as slots free. + maxConnections: 8 + + -- The number of seconds a transfer can go without receiving any data before we consider + -- it stalled and abort it. Set to 0 or false to disable stall detection. + stallTimeout: 30 + --- Creates a downloader. - -- @param[opt] runner function(downloader, callback) overrides the transfer implementation - new: (runner) => + -- @param runner function(downloader, callback) overrides the transfer implementation + -- @param options? table additional options + new: (runner, options = {}) => super! + + @stallTimeout = options.stallTimeout if options.stallTimeout != nil + @maxConnections = options.maxConnections if options.maxConnections != nil + @downloads = {} @cancelled = false @_runner = runner or defaultRunner diff --git a/modules/DependencyControl/EventEmitter.moon b/modules/DependencyControl/EventEmitter.moon new file mode 100644 index 0000000..833ee0c --- /dev/null +++ b/modules/DependencyControl/EventEmitter.moon @@ -0,0 +1,38 @@ +--- 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] From 3d5c5bb60dbfa0efccaa284938a1031821122cea Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 2 Jun 2026 01:39:09 +0200 Subject: [PATCH 31/47] feat: new FileOps.writeFile() method --- modules/DependencyControl/FileOps.moon | 29 +++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index f0fb00d..29f70d7 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -35,7 +35,6 @@ class FileOps otherExists: "Couldn't create directory because a %s of the same name is already present." } copy: { - targetExists: "Target file '%s' already exists" genericError: "An error occured while copying file '%s' to '%s':\n%s" dirCopyUnsupported: "Copying directories is currently not supported." missingSource: "Couldn't find source file '%s'." @@ -59,6 +58,12 @@ 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." } + writeFile: { + cantOpen: "Couldn't open file '%s' for writing: %s" + failedWrite: "An error occurred while trying to write to file '%s': %s", + notAFile: "Can only write to files but supplied path '%s' points to a %s.", + targetExists: "Target file '%s' already exists." + } verifyHash: { badHash: "Argument #2 (hash) must be a string, got '%s'." mismatch: "Hash mismatch. Got %s, expected %s." @@ -82,7 +87,7 @@ class FileOps validateFullPath: { badType: "Argument #1 (path) had the wrong type. Expected 'string', got '%s'." tooLong: "The specified path exceeded the maximum length limit (%d > %d)." - invalidChars: "The specifed path contains one or more invalid characters: '%s'." + invalidChars: "The specified path contains one or more invalid characters: '%s'." reservedNames: "The specified path contains reserved path or file names: '%s'." parentPath: "Accessing parent directories is not allowed." notFullPath: "The specified path is not a valid full path." @@ -217,7 +222,7 @@ class FileOps mode, targetFullPath = FileOps.attributes target, "mode" switch mode when "file" - return false, msgs.copy.targetExists\format target + return false, msgs.writeFile.targetExists\format target when nil return false, msgs.copy.genericError\format source, target, targetFullPath when "directory" @@ -337,6 +342,24 @@ class FileOps if data return data else return nil, msgs.readFile.cantRead\format path, msg + + --- Writes data to a file, creating the file if it doesn't exist and optionally overwriting existing files. + -- @param path string|string[] path or path segments to the file to write + -- @param data string the data to write to the file + -- @param[opt=false] clobber boolean whether to overwrite the file if it already exists + -- @return boolean success true if the file was written successfully + writeFile: (path, data, clobber = false) -> + mode, fullPath = FileOps.attributes path, "mode" + return false, msgs.writeFile.notAFile\format path, mode if mode and mode ~= "file" + return false, msgs.writeFile.targetExists\format path if mode == "file" and not clobber + + handle, msg = io.open fullPath, "wb" + return false, msgs.writeFile.cantOpen\format fullPath, msg unless handle + + success, msg = handle\write data + handle\close! + return true if success + return false, msgs.writeFile.failedWrite\format fullPath, msg --- Computes the hash of a file's contents. -- @param fileName string|string[] path or path segments to the file to hash From e4b76cc9e7e73ced35295fb30ad6e39df9aa6022 Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 2 Jun 2026 01:40:17 +0200 Subject: [PATCH 32/47] test: add downloader integration tests; allow unit test to be conditionally skipped --- modules/DependencyControl/Tests.moon | 81 +++++++++++ modules/DependencyControl/UnitTestSuite.moon | 84 +++++++++--- .../helpers/MockHttpServerController.moon | 102 ++++++++++++++ .../test/helpers/mock-http-server.moon | 127 ++++++++++++++++++ 4 files changed, 374 insertions(+), 20 deletions(-) create mode 100644 modules/DependencyControl/test/helpers/MockHttpServerController.moon create mode 100644 modules/DependencyControl/test/helpers/mock-http-server.moon diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon index 9891ee7..cd7dac6 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -2106,4 +2106,85 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> "getMacro_usesAutomationType", "getModule_usesModuleType" } } + + -- Real-HTTP exercise of the Downloader backends against a local pegasus/copas server + -- (test/helpers/mock-http-server). Self-gating via _condition: skipped unless the server's + -- Lua deps are installed, so the default offline run never needs luasocket/copas/pegasus. + DownloaderIntegration: { + _description: "Real-HTTP Downloader tests against a local test server (runs when launchable)." + + -- The controller is required lazily and pcall-guarded, so this is harmless where the test + -- helpers aren't reachable (e.g. a stripped-down install) — it just skips. + _condition: -> + ok, MockServerController = pcall require, "l0.DependencyControl.test.helpers.MockHttpServerController" + return false, "mock server helper unavailable (#{MockServerController})" unless ok + isReady, err = MockServerController\isReady! + return false, "mock server is not ready to start: #{err}" unless isReady + return true + + _setup: (ut) -> + MockServerController = require "l0.DependencyControl.test.helpers.MockHttpServerController" + base = "#{basePath}_downloader" + serveDir, downloadDir = "#{base}/fixtures", "#{base}/out" + FileOps.mkdir d, false, true for d in *{base, serveDir, downloadDir} + + -- deterministic pseudo-random bytes (reproducible, no rng seeding dependency) + makeBytes = (n) -> + t, x = {}, 0x1234567 + for i = 1, n + x = (x * 1103515245 + 12345) % 0x80000000 + t[i] = string.char x % 256 + table.concat t + + fixtures = {} + for spec in *{ {"small.bin", 2048}, {"medium.bin", 64 * 1024}, {"large.bin", 256 * 1024} } + name, size = spec[1], spec[2] + path = "#{serveDir}/#{name}" + f = assert io.open path, "wb" + f\write makeBytes size + f\close! + sha1 = assert FileOps.getHash path, FileOps.HashType.SHA1 + fixtures[#fixtures + 1] = {:name, :sha1} + + server = MockServerController :serveDir + server\start! + {:server, :fixtures, :downloadDir} + + _teardown: (ut, ctx) -> + ctx.server\stop! if ctx and ctx.server + + -- all transfers at full speed, fired together: every file must arrive and verify (sha1) + concurrentFast: (ut, ctx) -> + dm, dls = Downloader!, {} + for f in *ctx.fixtures + dls[f.name] = dm\addDownload "#{ctx.server.baseUrl}/fast/#{f.name}", "#{ctx.downloadDir}/#{f.name}", f.sha1 + dm\await! + ut\assertEquals dls[f.name].status, Downloader.Download.Status.Finished for f in *ctx.fixtures + + -- chunked, throttled transfers kept in flight at once: the real concurrency stress + concurrentSlow: (ut, ctx) -> + dm, dls = Downloader!, {} + for f in *ctx.fixtures + dls[f.name] = dm\addDownload "#{ctx.server.baseUrl}/slow/#{f.name}?delay=20&chunk=4096", "#{ctx.downloadDir}/slow_#{f.name}", f.sha1 + dm\await! + ut\assertEquals dls[f.name].status, Downloader.Download.Status.Finished for f in *ctx.fixtures + + -- more downloads than connection slots: all must still complete (windowed scheduler) + queuedBeyondLimit: (ut, ctx) -> + f = ctx.fixtures[1] + dm, dls = Downloader(nil, {maxConnectionsPerServer: 2}), {} + for i = 1, 5 + dls[i] = dm\addDownload "#{ctx.server.baseUrl}/slow/#{f.name}?delay=20&chunk=1024", "#{ctx.downloadDir}/q#{i}.bin", f.sha1 + dm\await! + ut\assertEquals dls[i].status, Downloader.Download.Status.Finished for i = 1, 5 + + -- a non-2xx response must fail the transfer, not hang or report success + httpError: (ut, ctx) -> + dm = Downloader! + dl = dm\addDownload "#{ctx.server.baseUrl}/status/404", "#{ctx.downloadDir}/missing.bin" + dm\await! + ut\assertEquals dl.status, Downloader.Download.Status.Failed + + _order: { "concurrentFast", "concurrentSlow", "queuedBeyondLimit", "httpError" } + } } diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon index 2055cc0..575f426 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/DependencyControl/UnitTestSuite.moon @@ -576,8 +576,13 @@ class UnitTestClass abort: "Test class '%s' FAILED after %d tests, aborting." testsFailed: "Done testing class '%s'. FAILED %d of %d tests." success: "Test class '%s' completed successfully." + skipped: "Test class '%s' SKIPPED (%s)." + teardownFailed: "Teardown for test class '%s' FAILED." testNotFound: "Couldn't find requested test '%s'." } + skipReason: { + default: "condition not met" + } } --- Creates a new unit test class complete with a number of unit test as well as optional setup and teardown. @@ -589,6 +594,10 @@ class UnitTestClass -- * _setup: a @{UnitTestSetup} routine -- * _teardown: a @{UnitTestTeardown} routine -- * _order: alternative syntax to the order parameter (see below) + -- * _condition: a predicate `() -> boolean[, string reason]`. Evaluated before the class + -- runs; if it returns a falsy value the whole class is skipped and its tests are marked + -- as skipped (with the optional reason) in the report rather than run. Use it to gate + -- environment-dependent tests, e.g. `_condition: -> os.getenv("DEPCTRL_INTEGRATION") == "1"`. -- @tparam [opt=nil (unordered)] {string, ...} A list of test names in the desired execution order. -- Only tests mentioned in this table will be performed when running the whole test class. -- If unspecified, all tests will be run in random order. @@ -596,7 +605,9 @@ class UnitTestClass @logger = @testSuite.logger @setup = UnitTestSetup "setup", args._setup, @ @teardown = UnitTestTeardown "teardown", args._teardown, @ + @hasTeardown = args._teardown != nil @description = args._description + @condition = args._condition @order or= args._order @tests = [UnitTest(name, f, @) for name, f in pairs args when "_" != name\sub 1,1] @@ -607,6 +618,19 @@ class UnitTestClass -- @treturn[2] boolean false (test class failed) -- @treturn[2] {@{UnitTest}, ...} a list of unit test that failed run: (abortOnFail, order = @order) => + -- class-level skip condition: when the predicate returns falsy, skip the whole class + -- and mark its tests as skipped so they still surface (as skipped) in the report. + -- Call without `self` (plain `cond!`, not `@condition!`) so the predicate isn't handed + -- the class as an unexpected first argument. + if cond = @condition + shouldRun, reason = cond! + unless shouldRun + @skipped, @skipReason = true, reason + for test in *@tests + test.skipped, test.skipReason = true, reason + @logger\log msgs.run.skipped, @name, reason or msgs.skipReason.default + return true -- a skipped class is not a failure + tests, failed = @tests, {} if order tests, mappings = {}, {test.name, test for test in *@tests} @@ -619,24 +643,33 @@ class UnitTestClass @logger.indent += 1 success, res = @setup\run! - -- failing the setup always aborts + -- failing the setup always aborts (no teardown: setup never completed) unless success @logger.indent -= 1 @logger\warn msgs.run.setupFailed, @name return false, -1 + aborted = false for i, test in pairs tests unless test\run unpack res failedCnt += 1 failed[#failed+1] = test if abortOnFail - @logger.indent -= 1 @logger\warn msgs.run.abort, @name, i - return false, failed + aborted = true + break + + -- teardown runs after the tests whenever setup succeeded — including the abort path — + -- so resource cleanup is reliable. It's best-effort: a teardown failure is logged but + -- doesn't change the class result. Setup's return values are passed through to it. + if @hasTeardown + @logger\warn msgs.run.teardownFailed, @name unless @teardown\run unpack res @logger.indent -= 1 @success = failedCnt == 0 + if aborted + return false, failed if @success @logger\log msgs.run.success, @name return true @@ -763,10 +796,10 @@ class UnitTestSuite return @success, failedCnt > 0 and allFailed or nil --- Collects the results of the most recent run into a flat, format-agnostic structure. - -- Only tests that actually ran are included; a failed class setup surfaces as an errored - -- "setup" case so skipped classes still show up in the report. + -- Tests that ran or were skipped are included; a failed class setup surfaces as an errored + -- "setup" case so aborted classes still show up in the report. -- @local - -- @treturn {{name=string, cases={{name, classname, duration, failure?, error?}, ...}}, ...} + -- @treturn {{name=string, cases={{name, classname, duration, failure?, error?, skipped?, skipReason?}, ...}}, ...} collectResults: => suites = {} for cls in *@classes @@ -776,14 +809,18 @@ class UnitTestSuite cases[#cases+1] = { name: "setup", classname: cls.name, duration: cls.setup.duration or 0, error: cls.setup.errMsg } for test in *cls.tests - continue unless test.ran - cases[#cases+1] = { - name: test.name, classname: cls.name, duration: test.duration or 0 + continue unless test.ran or test.skipped + case = { name: test.name, classname: cls.name, duration: test.duration or 0 } + if test.skipped + case.skipped, case.skipReason = true, test.skipReason + elseif not test.success -- keep assertion failures and unexpected errors separate for consumers -- that care; CTRF itself folds both into a single "failed" status - failure: not test.success and test.assertFailed and (test.errMsg or "assertion failed") or nil - error: not test.success and not test.assertFailed and (test.errMsg or "unexpected error") or nil - } + if test.assertFailed + case.failure = test.errMsg or "assertion failed" + else + case.error = test.errMsg or "unexpected error" + cases[#cases+1] = case suites[#suites+1] = { name: cls.name, :cases } return suites @@ -792,27 +829,34 @@ class UnitTestSuite -- (e.g. the ctrf-io/github-test-reporter action). See https://ctrf.io. -- @treturn table the CTRF report as a plain Lua table, ready to be JSON-encoded toCtrf: => - tests, passed, failed = {}, 0, 0 + tests, passed, failed, skipped = {}, 0, 0, 0 for suite in *@collectResults! for c in *suite.cases - failureMsg = c.failure or c.error -- CTRF has a single "failed" status - if failureMsg then failed += 1 else passed += 1 entry = { name: c.name suite: c.classname - status: failureMsg and "failed" or "passed" duration: math.floor c.duration * 1000 + 0.5 -- seconds -> ms } - entry.message = failureMsg if failureMsg + if c.skipped + skipped += 1 + entry.status = "skipped" + entry.message = c.skipReason if c.skipReason + elseif c.failure or c.error + failed += 1 + entry.status = "failed" + entry.message = c.failure or c.error -- CTRF folds assert/error into "failed" + else + passed += 1 + entry.status = "passed" tests[#tests+1] = entry return { results: { tool: { name: "DependencyControl.UnitTestSuite" } summary: { - tests: passed + failed - :passed, :failed - pending: 0, skipped: 0, other: 0 + tests: passed + failed + skipped + :passed, :failed, :skipped + pending: 0, other: 0 start: @startTime or 0 stop: @endTime or 0 } diff --git a/modules/DependencyControl/test/helpers/MockHttpServerController.moon b/modules/DependencyControl/test/helpers/MockHttpServerController.moon new file mode 100644 index 0000000..661d2f1 --- /dev/null +++ b/modules/DependencyControl/test/helpers/MockHttpServerController.moon @@ -0,0 +1,102 @@ +-- Controls a mock HTTP server subprocess for the Downloader integration tests. +-- +-- Safe to require anywhere (including Aegisub): loading it only defines the class. luasocket is +-- pulled in lazily, when a server is actually started/stopped. The server itself runs in a +-- separate process (await blocks, so it can't share our thread); we compile mock-http-server.moon +-- to plain Lua up front so that process needs only a bare interpreter, no MoonScript. + +ffi = require "ffi" +moonbase = require "moonscript.base" +FileOps = require "l0.DependencyControl.FileOps" + +MOCK_SERVER_FILE_BASENAME = "mock-http-server" + +isWindows = ffi.os == "Windows" +interpreter = (arg and arg[-1]) or "luajit" -- run the server under the interpreter running us + +-- mock-http-server.moon sits next to this file; locate it from our own source path. +_, device, dir = FileOps.validateFullPath debug.getinfo(1, "S").source\gsub("^@", ""), true +serverSourcePath = FileOps.joinPath "#{device}#{dir}", "#{MOCK_SERVER_FILE_BASENAME}.moon" + +quote = (s) -> "\"#{tostring(s)}\"" + +spawnDetached = (cmd) -> + os.execute isWindows and "start \"\" /b #{cmd}" or "#{cmd} >/dev/null 2>&1 &" + +-- os.execute on Windows mis-parses a command that begins with a quote unless the whole command +-- is wrapped in one more pair of quotes (cmd /c then strips the outer pair). +runBlocking = (cmd) -> os.execute isWindows and quote(cmd) or cmd + +class MockHttpServerController + -- Compile mock-http-server.moon to a throwaway .lua once and cache the path. + @compileServer = => + return @compiledServerPath if @compiledServerPath + + serverSource = assert FileOps.readFile serverSourcePath + compiledServerLua = assert moonbase.to_lua serverSource + tempDir = assert FileOps.createTempDir! + path = FileOps.joinPath tempDir, "#{MOCK_SERVER_FILE_BASENAME}.lua" + assert FileOps.writeFile path, compiledServerLua + @compiledServerPath, @compiledServerTempDir = path, tempDir + return @compiledServerPath + + --- Whether the server can be launched here, i.e. its Lua dependencies (luasocket, copas, + --- pegasus) are installed. Spawns the server with --check, which loads the deps and exits + --- 0/1 without serving — so this needs no luasocket in our own process. + @isReady: => + success, errMsg = pcall @compileServer, @ + return false, "mock server compilation failed: #{errMsg}" unless success + return true if runBlocking("#{quote interpreter} #{quote @compiledServerPath} --check") + return false, "mock server dependencies (luasocket/copas/pegasus) not available" + + --- @param[opt] opts table: dir (directory whose files to serve), maxLifetime (server + --- self-destruct timeout in seconds), timeout (readiness wait in seconds) + new: (opts = {}) => + @serveDir = opts.serveDir or "." + @maxLifetime = opts.maxLifetime or 120 + @timeout = opts.timeout or 10 + + --- Picks a free loopback port, starts the server on it and waits until it's listening. + -- @return self + start: => + socket = require "socket" + -- Grab a free port by binding to 0, then hand it to the server via --port. (Tiny race + -- between closing and the server re-binding, but it's loopback and a throwaway server.) + probe = assert socket.bind "127.0.0.1", 0 + _, port = probe\getsockname! + probe\close! + @port, @baseUrl = port, "http://127.0.0.1:#{port}" + + startCommand = table.concat { + quote(interpreter), quote(@@compileServer!), + "--port", tostring(port), + "--dir", quote(@serveDir), + "--max-lifetime", tostring(@maxLifetime), + }, " " + io.stderr\write "Starting mock HTTP server with command: #{startCommand}...\n" + spawnDetached startCommand + + -- ready once the port accepts a connection + deadline = socket.gettime! + @timeout + while socket.gettime! < deadline + conn = socket.tcp! + conn\settimeout 0.2 + connected = conn\connect "127.0.0.1", port + conn\close! + return @ if connected + socket.sleep 0.05 + error "mock HTTP server didn't start on port #{port} within #{@timeout}s" + + --- Stops the server via its /__quit route. Best-effort: if luasocket isn't available here, + --- the server's max-lifetime cleans it up regardless. + stop: => + return unless @port + pcall -> + socket = require "socket" + conn = socket.tcp! + conn\settimeout 2 + if conn\connect "127.0.0.1", @port + conn\send "GET /__quit HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" + conn\receive "*a" -- wait for the response / server exit + conn\close! + @port, @baseUrl = nil, nil diff --git a/modules/DependencyControl/test/helpers/mock-http-server.moon b/modules/DependencyControl/test/helpers/mock-http-server.moon new file mode 100644 index 0000000..471de57 --- /dev/null +++ b/modules/DependencyControl/test/helpers/mock-http-server.moon @@ -0,0 +1,127 @@ +-- Standalone HTTP mock server for DependencyControl's Downloader integration tests. +-- +-- Built on pegasus (HTTP) + copas (concurrency). pegasus' own `server\start` serves one +-- connection at a time; running it under copas (as below) is what makes it handle many +-- connections concurrently — the whole point here, since the Downloader's scheduling can only +-- be stressed with several transfers genuinely in flight at once. +-- +-- This is MoonScript, but it's launched in a *fresh* interpreter, so MockHttpServerController +-- compiles it to plain Lua first (the spawned process needs no MoonScript). Endpoints serve +-- files from --dir: +-- GET /fast/ full-speed response (Content-Length) +-- GET /slow/?delay=&chunk= chunked response, bytes every ms +-- GET /status/ respond with the given HTTP status +-- GET /__quit stop the server (the clean shutdown route) +-- +-- Flags: --port (loopback port to listen on), --dir , --max-lifetime (orphan +-- safety, default 120), --check (verify deps load, then exit 0/1 without starting a server). + +-- "--flag value" / "--flag" parser (a flag with no following value is a boolean) + +CONTENT_TYPE_HEADER_NAME = "Content-Type" +MIME_TYPE_BINARY = "application/octet-stream" +LOCALHOST_IP = "127.0.0.1" + +parseArgs = (argv) -> + opts, i = {}, 1 + while argv[i] + flag = argv[i]\match "^%-%-(.+)$" + if flag + value = argv[i + 1] + if value and not value\match "^%-%-" + opts[flag], i = value, i + 2 + else + opts[flag], i = true, i + 1 + else + i += 1 + opts + +opts = parseArgs arg or {} + +-- --check: confirm the server's dependencies are installed, then exit. Lets the integration +-- tests gate themselves on "can we actually launch the server here?" without an env var. +depsOk = pcall -> + require "socket" + require "copas" + require "pegasus.handler" + +if opts.check + os.exit depsOk and 0 or 1 +assert depsOk, "mock server dependencies (luasocket, copas, pegasus) are not available" + +socket = require "socket" +copas = require "copas" +Handler = require "pegasus.handler" + +listenPort = assert tonumber(opts.port), "--port is required" +serveDir = opts.dir or "." +maxLifetime = tonumber(opts["max-lifetime"]) or 120 + +readFile = (path) -> + f = io.open path, "rb" + return nil unless f + data = f\read "*a" + f\close! + data + +-- map a request name to a file inside serveDir, rejecting traversal +resolve = (name) -> + return nil if not name or name == "" or name\find "%.%." + "#{serveDir}/#{name}" + +quitRequested = false + +handleRequest = (req, res) -> + path = req\path! + + if path == "/__quit" + res\statusCode 200 + res\write "bye" + quitRequested = true + return res\close! + + if code = path\match "^/status/(%d+)$" + res\statusCode tonumber code + res\write "status #{code}" + return res\close! + + if name = path\match "^/fast/(.+)$" + data = readFile resolve name + return res\statusCode(404)\write("not found") unless data + res\statusCode 200 + res\addHeader CONTENT_TYPE_HEADER_NAME, MIME_TYPE_BINARY + res\write data -- non-streaming: Content-Length, single send + return res\close! + + if name = path\match "^/slow/(.+)$" + data = readFile resolve name + return res\statusCode(404)\write("not found") unless data + delayMs = tonumber(req.querystring.delay) or 50 + chunk = tonumber(req.querystring.chunk) or 1024 + res\statusCode 200 + res\addHeader CONTENT_TYPE_HEADER_NAME, MIME_TYPE_BINARY + for i = 1, #data, chunk + res\write data\sub(i, i + chunk - 1), true -- stayOpen => chunked + copas.sleep delayMs / 1000 -- yields, so other transfers flow + return res\close! + + res\statusCode 404 + res\write "unknown endpoint" + res\close! + +-- bind to the loopback port the controller chose for us +server = assert socket.bind LOCALHOST_IP, listenPort + +handler = Handler\new handleRequest, serveDir, {}, nil +copas.addserver server, copas.handler (client) -> handler\processRequest listenPort, client + +io.stderr\write "mock-http-server listening on #{LOCALHOST_IP}:#{listenPort} (dir=#{serveDir})\n" + +-- shut down on the /__quit route, or after max-lifetime so we can never orphan +startedAt = os.time! +copas.addthread -> + while true + copas.sleep 0.1 + os.exit 0 if quitRequested or os.time! - startedAt > maxLifetime + +copas.loop! From a77c38fe1e8e81ae25354e7dd5f9f984b3c3a764 Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 2 Jun 2026 20:00:26 +0200 Subject: [PATCH 33/47] refactor: TerribleMutex [squash] --- modules/DependencyControl/TerribleMutex.moon | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/DependencyControl/TerribleMutex.moon b/modules/DependencyControl/TerribleMutex.moon index 496ffe3..e6bbe82 100644 --- a/modules/DependencyControl/TerribleMutex.moon +++ b/modules/DependencyControl/TerribleMutex.moon @@ -37,8 +37,9 @@ if ffi.os == "Windows" (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 + O_CREAT = ffi.os == "OSX" and 0x200 or 0x40 -- open syscall flag: create a file if it doesn't exist + FILE_MODE_664 = 0x1a4 + BINARY_SEMAPHORE_INITIAL_VALUE = 1 pcall ffi.cdef, [[ int getpid(void); @@ -52,8 +53,7 @@ else 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 + sem = ffi.C.sem_open name, O_CREAT, FILE_MODE_664, BINARY_SEMAPHORE_INITIAL_VALUE tryLock = -> ffi.C.sem_trywait(sem) == 0 lock = -> ffi.C.sem_wait sem From 59d6531f44ea4d4aba37441a10adab95e73d62be Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 2 Jun 2026 20:02:30 +0200 Subject: [PATCH 34/47] test: add additional tests for FileOps.validateFullPath and Common.validateNamespace --- modules/DependencyControl/Tests.moon | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon index cd7dac6..dd68eb3 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -428,11 +428,17 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> ut\assertFalse result ut\assertString err + validateNamespace_consecutiveDots: (ut) -> + result, err = Common.validateNamespace "foo..bar" + ut\assertFalse result + ut\assertString err + _order: { "capitalizeTerms", "validateNamespace_valid", "validateNamespace_multiPart", "validateNamespace_noDot", "validateNamespace_leadingDot", - "validateNamespace_trailingDot", "validateNamespace_invalidChars" + "validateNamespace_trailingDot", "validateNamespace_invalidChars", + "validateNamespace_consecutiveDots" } } @@ -464,6 +470,15 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> result = FileOps.validateFullPath {basePath, "CON", "file.txt"} ut\assertFalse result + validateFullPath_reservedNameWithExt: (ut) -> + return unless isWindows + result = FileOps.validateFullPath {basePath, "NUL.txt"} + ut\assertFalse result + + validateFullPath_trailingDotSegment: (ut) -> + result = FileOps.validateFullPath {basePath, "trailingdot.", "file.txt"} + ut\assertFalse result + validateFullPath_valid: (ut) -> path, dev, dir, file = FileOps.validateFullPath {basePath, "file.txt"} ut\assertString path @@ -486,6 +501,11 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> ut\assertString result ut\assertContains result, home + validateFullPath_reservedNameNonWindows: (ut) -> + return if isWindows + result = FileOps.validateFullPath {basePath, "NUL", "file.txt"} + ut\assertString result + -- getNamespacedPath: pure computation, no stubs needed getNamespacedPath_nested: (ut) -> @@ -675,8 +695,9 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> _order: { "validateFullPath_nonString", "validateFullPath_parentDir", "validateFullPath_tooLong", "validateFullPath_invalidChars", "validateFullPath_reservedNames", + "validateFullPath_reservedNameWithExt", "validateFullPath_trailingDotSegment", "validateFullPath_valid", "validateFullPath_noExt_rejected", "validateFullPath_withExt_accepted", - "validateFullPath_homeDirExpansion", + "validateFullPath_homeDirExpansion", "validateFullPath_reservedNameNonWindows", "getNamespacedPath_nested", "getNamespacedPath_flat", "getNamespacedPath_badNamespace", "getNamespacedPath_badBasePath", "attributes_file", "attributes_notFound", From 1c770a6ece691ce44401e5c4e9384b925507391f Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 2 Jun 2026 22:13:15 +0200 Subject: [PATCH 35/47] docs: add JSON schema for the DepCtrl feed --- .../DependencyControl/ScriptUpdateRecord.moon | 2 +- schemas/feed/v0.3.0.json | 247 ++++++++++++++++++ 2 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 schemas/feed/v0.3.0.json diff --git a/modules/DependencyControl/ScriptUpdateRecord.moon b/modules/DependencyControl/ScriptUpdateRecord.moon index 4d08fce..419ff76 100644 --- a/modules/DependencyControl/ScriptUpdateRecord.moon +++ b/modules/DependencyControl/ScriptUpdateRecord.moon @@ -14,7 +14,7 @@ defaultLogger = Logger fileBaseName: "DepCtrl.ScriptUpdateRecord" ---@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 released? string ISO 8601 release date string (e.g. "2024-01-31" or "2024-01-31T23:59:00Z") ---@field fileBaseUrl? string Base URL prepended to file names during template expansion. ---@class FeedScriptData diff --git a/schemas/feed/v0.3.0.json b/schemas/feed/v0.3.0.json new file mode 100644 index 0000000..c92fe89 --- /dev/null +++ b/schemas/feed/v0.3.0.json @@ -0,0 +1,247 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/feed/v0.3.0.json", + "title": "DependencyControl Feed Format v0.3.0", + "description": "An index for a repository of automation macros and modules available for installation or update via DependencyControl.", + "type": "object", + "required": ["dependencyControlFeedFormatVersion", "name"], + "properties": { + "dependencyControlFeedFormatVersion": { + "enum": ["0.2.0", "0.3.0"], + "description": "Feed format version. This schema accepts '0.2.0' and '0.3.0'; both are handled correctly by a v0.3.0-capable reader. '0.3.0' introduced FeedFile.type for test files." + }, + "name": { + "description": "Human-readable name of the feed (e.g. repository or collection name).", + "type": "string" + }, + "description": { + "description": "Short description of the feed's contents.", + "type": "string" + }, + "baseUrl": { + "$ref": "#/$defs/TemplateString", + "description": "Root URL of the project. Becomes the @{baseUrl} template variable available throughout the feed." + }, + "url": { + "$ref": "#/$defs/TemplateString", + "description": "Feed's own URL or homepage. May reference @{baseUrl}." + }, + "fileBaseUrl": { + "$ref": "#/$defs/TemplateString", + "description": "Default base URL for file downloads. Becomes a rolling @{fileBaseUrl} variable: once set it propagates down to all channels that do not define their own fileBaseUrl." + }, + "maintainer": { + "description": "Name of the person or organization maintaining this feed.", + "type": "string" + }, + "knownFeeds": { + "description": "Named registry of other feed URLs, which will be used to discover additional packages (and feeds). Each key becomes a feed alias usable as `@{feed:alias}` in any TemplateString throughout this feed.", + "type": "object", + "additionalProperties": { + "description": "Absolute URL of a DependencyControl feed.", + "type": "string", + "format": "uri" + } + }, + "macros": { + "description": "Aegisub automation scripts keyed by their namespaced identifiers.", + "type": "object", + "additionalProperties": { "$ref": "#/$defs/Package" }, + "propertyNames": { "$ref": "#/$defs/NamespacedIdentifier" } + }, + + "modules": { + "description": "Lua module entries keyed by their namespaced identifiers.", + "type": "object", + "additionalProperties": { "$ref": "#/$defs/Package" }, + "propertyNames": { "$ref": "#/$defs/NamespacedIdentifier" } + } + }, + "$defs": { + "NamespacedIdentifier": { + "description": "A dot-separated namespaced identifier (e.g. 'l0.DependencyControl.Toolbox', 'a-mo.LineCollection').", + "type": "string", + "pattern": "^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+$" + }, + + "Changelog": { + "description": "Version-keyed changelog. Keys are version strings; values are a single change description or an array of descriptions.", + "type": "object", + "additionalProperties": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "minItems": 1 } + ] + }, + "propertyNames": { "$ref": "#/$defs/SemanticVersionWithoutLabels" } + }, + + "DateOrDateTime": { + "description": "A date or date-time string in ISO 8601 format (e.g. '2024-01-31' or '2024-01-31T23:59:00Z').", + "anyOf": [ + { "type": "string", "format": "date" }, + { "type": "string", "format": "date-time" } + ] + }, + + "SemanticVersionWithoutLabels": { + "description": "Semantic version string *without* pre-release or build metadata (..).", + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + + "TemplateString": { + "description": "A string that may contain `@{variableName}` or `@{feed:alias}` template substitutions. Variables are expanded before the value is used. Recognized variable names at each depth level: `baseUrl`, `fileBaseUrl` (rolling), `url`, `namespace`, `namespacePath`, `scriptName`, `channel`, `version`, `platform`, `fileName`. Cross-feed references use the `@{feed:alias}` form, where alias is a key in knownFeeds (e.g. `@{feed:a-mo}`).", + "type": "string" + }, + + "Sha1Hash": { + "description": "SHA-1 digest as 40 hexadecimal characters (case-insensitive).", + "type": "string", + "pattern": "^[0-9A-Fa-f]{40}$" + }, + + "PackagedFile": { + "description": "A single file included in a release.", + "type": "object", + "required": ["name", "url", "sha1"], + "properties": { + "name": { + "description": "File path relative to the release channel's effective `@{fileBaseUrl}` template variable value. Conventionally starts with '.' for the primary module file (e.g. '.moon') or '/' for sub-files (e.g. '/Common.moon'). Template variables are NOT expanded in this field. It is provided verbatim as the `@{fileName}` template variable in the 'url' field.", + "type": "string" + }, + "url": { + "$ref": "#/$defs/TemplateString", + "description": "Absolute download URL after template expansion. Defaults to the file name appended to the file base URL when omitted.", + "default": "@{fileBaseUrl}@{fileName}" + }, + "sha1": { + "$ref": "#/$defs/Sha1Hash", + "description": "Expected SHA-1 digest of the downloaded file. Used to verify integrity after download." + }, + "type": { + "description": "An optional special purpose for the file. Files marked with 'test' are installed to the tests subdirectory rather than the main automation directory. Introduced in feed version 0.3.0.", + "type": "string", + "enum": ["test"] + }, + "platform": { + "description": "Target platform filter. When present, the file is only installed on matching platforms. Absent means the file is installed on all platforms. Uses LuaJIT `jit.os`and `jit.arch` values (see https://luajit.org/ext_jit.html#jit_os).", + "type": "string", + "examples": ["Windows-x64", "OSX-x86", "Linux-arm64"] + } + } + }, + + "ModuleDependency": { + "description": "A dependency declared by a script release.", + "type": "object", + "required": ["moduleName"], + "properties": { + "moduleName": { + "description": "Lua require-path of the dependency (e.g. 'l0.DependencyControl', 'json', 'aegisub.re').", + "type": "string" + }, + "name": { + "description": "Human-readable display name shown in install/update dialogs.", + "type": "string" + }, + "url": { + "description": "Project or homepage URL for the dependency.", + "type": "string", + "format": "uri" + }, + "version": { + "$ref": "#/$defs/SemanticVersionWithoutLabels", + "description": "Minimum required version. When omitted any installed version is accepted." + }, + "feed": { + "$ref": "#/$defs/TemplateString", + "description": "URL (or @{feed:alias} reference) of the feed that can supply this dependency. Used by the Updater to locate and install missing modules." + }, + "optional": { + "description": "When true, the dependency is installed if available / loaded if present but its absence is not an error.", + "type": "boolean", + "default": false + } + } + }, + + "Release": { + "description": "A versioned release of an automation script or module currently available on a channel.", + "type": "object", + "required": ["version", "files"], + "properties": { + "version": { + "$ref": "#/$defs/SemanticVersionWithoutLabels", + "description": "The current release version on this channel." + }, + "released": { + "$ref": "#/$defs/DateOrDateTime", + "description": "The release date in ISO 8601 format (e.g. '2026-05-31' or '2026-05-31T23:59:00Z')." + }, + "default": { + "description": "Whether this is the channel selected when no user preference exists. Exactly one channel per script should set this to true.", + "type": "boolean", + "default": false + }, + "fileBaseUrl": { + "$ref": "#/$defs/TemplateString", + "description": "Base URL prepended to each file's name to form its download URL. Overrides any `@{fileBaseUrl}` template variable inherited from the script entry or feed root. This is a 'rolling' template variable: once set at a given depth it propagates to all nested levels until overridden." + }, + "files": { + "description": "Files included in this release.", + "type": "array", + "items": { "$ref": "#/$defs/PackagedFile" }, + "minItems": 1 + }, + "requiredModules": { + "description": "Dependencies that must be present or installed for the release to function correctly. DependencyControl will attempt to install missing dependencies from any known feed, so long as they have a namespaced identifier.", + "type": "array", + "items": { "$ref": "#/$defs/ModuleDependency" } + }, + "platforms": { + "description": "Compute platforms supported by this channel. When present, the channel is only offered on matching platforms. Absent means all platforms are supported.", + "type": "array", + "items": { "type": "string" } + } + } + }, + + "Package": { + "description": "A single Aegisub automation script or Lua module entry in the feed.", + "type": "object", + "required": ["name", "author", "channels"], + "properties": { + "name": { + "description": "Human-readable display name of the script or module.", + "type": "string" + }, + "author": { + "description": "The name of the person or organization that created/maintains this script or module.", + "type": "string" + }, + "description": { + "description": "Short description shown in install/update dialogs.", + "type": "string" + }, + "url": { + "$ref": "#/$defs/TemplateString", + "description": "Project or homepage URL. May reference the `@{baseUrl}` or `@{namespace}` template variables." + }, + "fileBaseUrl": { + "$ref": "#/$defs/TemplateString", + "description": "Default base URL for file downloads within this script. Overrides the feed-level `@{fileBaseUrl}` for this entry's channels." + }, + "channels": { + "description": "Available release channels keyed by channel name.", + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/Release" } + }, + "changelog": { + "$ref": "#/$defs/Changelog" + } + } + } + } +} From 16bf3156bae7c695520be527688bcd91baeae61b Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 2 Jun 2026 23:10:08 +0200 Subject: [PATCH 36/47] docs: update README.md --- README.md | 494 +++++++++++++++++++++++++++--------------------------- 1 file changed, 250 insertions(+), 244 deletions(-) diff --git a/README.md b/README.md index 3547254..4547c2b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -DependencyControl - Enterprise Aegisub Script Management --------------------------------------------------------- +# DependencyControl - Enterprise Aegisub Script Management DependencyControl provides versioning, automatic script update, dependency management and script management services to Aegisub macros and modules. @@ -15,18 +14,17 @@ __Features__: __Requirements__: - * Aegisub > 3.2.0 (e.g. [Plorkyeran's](http://plorkyeran.com/aegisub/) r8792+ or [my](http://files.line0.eu/builds/Aegisub/) git builds) + * Aegisub [v3.4.0+](https://github.com/TypesettingTools/Aegisub/releases) or releases of [arch1t3cht's Aegisub fork](https://github.com/arch1t3cht/Aegisub/releases) based on v3.4.0+. Older versions of Aegisub may work, but you're on your own if you run into any issues. -DependencyControl is self-contained: it bundles a JSON library (dkjson) and ships pure-FFI -implementations of the timer, mutex and download manager it needs, so no extra modules have to be -installed. If you do have the native [ffi-experiments](https://github.com/torque/ffi-experiments) -modules ([DownloadManager](https://github.com/torque/ffi-experiments/releases), -[BadMutex](https://github.com/torque/ffi-experiments/releases)) or another `json` module installed, -those are used in preference to the bundled fallbacks automatically. +DependencyControl is self-contained: it bundles a JSON library ([dkjson](https://dkolf.de/dkjson-lua/)), though if you have another `json` module installed, it is used instead. +It also now ships with pure-FFI +implementations of functionality previously provided by +[ffi-experiments](https://github.com/torque/ffi-experiments) +modules (_DownloadManager_, _BadMutex_, _PreciseTimer_). ---------------------------------- -### Documentation ### +## Table of Contents 1. [DependencyControl for Users](#dependency-control-for-users) 2. [Usage for Automation Scripts](#usage-for-automation-scripts) @@ -41,28 +39,36 @@ those are used in preference to the bundled fallbacks automatically. ---------------------------------- -### Dependency Control for Users ### +## Dependency Control for Users As an end-user you don't get to decide whether your scripts use DependencyControl or not, but you can control many aspects of its operation. The updater works out-of-the-box (for any script with an update feed) and is run automatically. -#### Install Instructions #### - 1. Download the latest DependencyControl release for your platform and unpack its contents to your Aegisub **user** automation directory. - Alternatively use one of the [provided Aegisub builds](http://files.line0.eu/builds/Aegisub/) with built-in DependencyControl. +### Installation - _It is essential DependencyControl and all scripts it's used reside in the **user** automation directory, **NOT** the the automation directory in the Aegisub application folder._ + 1. Download the latest DependencyControl release unpack its contents to your Aegisub **user** automation directory: - On Windows, this will be `%AppData%\Aegisub\automation` folder. + - On Windows: `%AppData%\Aegisub\automation` + - On Linux: `~/.aegisub/automation` + - On OSX: `~/Library/Application Support/Aegisub/automation` -2. In Aegisub, rescan your automation folder (or restart Aegisub). + Do **NOT** unpack the file into the automation directory within the Aegisub installation folder, as this will break the updater. -#### Configuration #### -DependencyControl comes with sane default settings, so if you're happy with that, there's no need to read further. If you want to disable the updater, use custom menus or want to tweak another aspect of DepedencyControl, read on. +2. Restart Aegisub or re-scan your autoload directory from within the Aegisub _Automation Manger_. -DependencyControl stores its configuration as a JSON file in the _config_ subdirectory of your Aegisub folder (`l0.DependencyControl.json`). Currently you'll have to edit this file manually, in the future there will be a management macro. +### Configuration + +DependencyControl comes with sane default settings, so if you're happy with that, there's no need to read further. If you want to disable the updater, use custom menus or want to tweak another aspect of DependencyControl, read on. + +DependencyControl stores its configuration as a JSON file in the `config` folder of your Aegisub user directory: +- On Windows: `%AppData%\Aegisub\config\l0.DependencyControl.json` +- On Linux: `~/.aegisub/config/l0.DependencyControl.json` +- On OSX: `~/Library/Application Support/Aegisub/config/l0.DependencyControl.json` + +The **DependencyControl Toolbox** macro provides a GUI for common management tasks; advanced options still require manual JSON editing. There are 2 kinds of configuration: -##### 1. Global Configuration ##### +#### 1. Global Configuration Changes made in the `config` section of the configuration file will affect all scripts and general DependencyControl behavior. __Available Fields__: @@ -70,16 +76,16 @@ __Available Fields__: * *bool* __updaterEnabled [true]:__ Turns the updater on/off * *int* __updateInterval [3 Days]:__ The time in seconds between two update checks of a script * *int* __traceLevel [3]:__ Sets the Trace level of DependencyControl update messages. Setting this higher than your _Trace level_ setting in Aegisub will prevent any of the messages from littering your log window. -* *bool* __dumpFeeds [true]:__ Debug option that will make DependencyControl dump updater feeds (original and expanded) to your Aegsiub folder. +* *bool* __dumpFeeds [true]:__ Debug option that will make DependencyControl dump updater feeds (original and expanded) to your Aegisub folder. * *arr* __extraFeeds:__ lets you provide additional update feeds that will be used when checking any script for updates -* *bool* __tryAllFeeds [false]:__ When set to true, feeds available to update a macro or module will be checked until an update is found. When set to false, a regular update process will stop once a feed confirms the script to be up-to-date. +* *bool* __tryAllFeeds [false]:__ When set to true (exhaustive mode), all candidate feeds are checked and the highest available version wins. When set to false (normal mode), the updater stops at the first feed that offers a newer version. * *str* __configDir ["?user/config"]:__ Sets the configuration directory that will be "offered" to automation scripts (they may or may not actually use it) -* *str* __writeLogs [true]:__ When enabled, DependencyControl log messages will be written to a file in the Aegisub log folder. This is a valuable resource for debugging, especially since the Aegisub log window is not available during script initalization. -* *int* __logMaxFiles [200]:__ DepedencyControl will purge old updater log files when any of the limits for log file count, log age and cumulative file size is exceeded. +* *str* __writeLogs [true]:__ When enabled, DependencyControl log messages will be written to a file in the Aegisub log folder. This is a valuable resource for debugging, especially since the Aegisub log window is not available during script initialization. +* *int* __logMaxFiles [200]:__ DependencyControl will purge old updater log files when any of the limits for log file count, log age and cumulative file size is exceeded. * *int* __logMaxAge [1 Week]:__ Logs with a last modified date that exceeds this limit will be deleted. Takes a duration in seconds. * *int* __logMaxSize [10 MB]:__ Cumulative file size limit for all log files in bytes. -##### 1. Per-script Configuration ##### +#### 2. Per-script Configuration Changes made in the `macros` and `modules` sections of the configuration file affect only the script or module in question. __Available Fields__: @@ -87,14 +93,14 @@ __Available Fields__: * *str* __customMenu:__ If you want to sort your automation macros into submenus, set this to the submenu name (use `/` to denote submenu levels). * *str* __userFeed:__ When set the updater will use this feed exclusively to update the script in question (instead of other feeds) * *int* __lastUpdateCheck [auto]:__ This field is used to store the (epoch) time of the last update check. -* *int* __logLevel [3]:__ sets the default trace level for log messages from this script (only applies to messages sent through a Logger instance provided by DepedencyControl to the script) +* *int* __logLevel [3]:__ sets the default trace level for log messages from this script (only applies to messages sent through a Logger instance provided by DependencyControl to the script) * *bool* __logToFile [false]:__ set the user preference wrt/ whether log messages of this script should be written to disk or not (same restrictions as above apply, may be overridden by the script) -* author, configFile, feed, moduleName, name, namespace, url, requiredModules, version, unmanaged: These fields hold aspects of the script's version record. Don't change them (they will be reset anyway) +* `author`, `configFile`, `feed`, `moduleName`, `name`, `namespace`, `url`, `requiredModules`, `version`, `unmanaged`, `provides`: These fields hold aspects of the script's version record. Don't change them (they will be reset anyway) ------------------------------------------ -### Usage for Automation Scripts ### -#### For Macros: #### +## Usage for Automation Scripts + +### For Macros Load DependencyControl at the start of your macro and create a version record. Script and version information is automatically pulled from the `script_*` variables (the additional `script_namespace` variable is **required**). @@ -109,86 +115,82 @@ script_namespace = "l0.MoveAlongPath" local DependencyControl = require("l0.DependencyControl") local version = DependencyControl{ - feed = "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/DependencyControl.json", - { - "aegisub.util", - {"a-mo.LineCollection", version="1.0.1", url="https://github.com/torque/Aegisub-Motion"}, - {"a-mo.Line", version="1.0.0", url="https://github.com/TypesettingTools/Aegisub-Motion"}, - {"a-mo.Log", url="https://github.com/torque/Aegisub-Motion"}, - {"l0.ASSFoundation", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", - feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, - {"l0.ASSFoundation.Common", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", - feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, - "YUtils" - } + feed = "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/DependencyControl.json", + { + "aegisub.util", + {"a-mo.LineCollection", version="1.0.1", url="https://github.com/torque/Aegisub-Motion"}, + {"a-mo.Line", version="1.0.0", url="https://github.com/TypesettingTools/Aegisub-Motion"}, + {"a-mo.Log", url="https://github.com/torque/Aegisub-Motion"}, + {"l0.ASSFoundation", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", + feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, + {"l0.ASSFoundation.Common", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", + feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, + "YUtils" + } } local util, LineCollection, Line, Log, ASS, Common, YUtils = version:requireModules() ``` -Specifying a feed in your own version record provides DepedencyControl with a source to download updates to your script from. +Specifying a feed in your own version record provides DependencyControl with a source to download updates to your script from. Specifying feeds for required modules managed by DependencyControl allows the Updater to discover those modules and fetch them when they're missing from the user's computer. However, you can omit the feed URLs for required modules when your own feed already has references to them. -To __register your macros__ use the following code snippets instead of the usual *aegisub.register_macro()* calls: +To **register your macros** use the following code snippets instead of the usual *aegisub.register_macro()* calls: -For a __single macro__ that should be registered using the *script_name* as automation menu entry, use: +For a **single macro** that should be registered using the *script_name* as automation menu entry, use: ```Lua version:registerMacro(myProcessingFunction) ``` -For a script that registers __several macros__ using its own submenu use: +For a script that registers **several macros** using its own submenu use: + ```Lua version:registerMacros{ - {script_name, "Opens the Move Along Path GUI", showDialog, validClip}, - {"Undo", "Reverts lines to their original state", undo, hasUndoData} + {script_name, "Opens the Move Along Path GUI", showDialog, validClip}, + {"Undo", "Reverts lines to their original state", undo, hasUndoData} } ``` -Using this method for macro registration is a requirement for the __custom submenus__ feature to work with your script and lets DependencyControl hook your macro processing function to run an update check when your macro is run. +Using this method for macro registration is a requirement for the **custom submenus** feature to work with your script and lets DependencyControl hook your macro processing function to run an update check when your macro is run. -#### For Modules: #### +### For Modules Creating a record for a module is very similar to how it does for macros, with the key difference being that name and version information is passed to DependencyControl correctly and a *moduleName* is required. ```lua - local DependencyControl = require("l0.DependencyControl") local version = DependencyControl{ - name = "ASSFoundation", - version = "0.1.1", - description = "General purpose ASS processing library", - author = "line0", - url = "http://github.com/TypesettingTools/ASSFoundation", - moduleName = "l0.ASSFoundation", - feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json", - { - "l0.ASSFoundation.ClassFactory", - "aegisub.re", "aegisub.util", "aegisub.unicode", - {"l0.ASSFoundation.Common", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", - feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, - {"a-mo.LineCollection", version="1.0.1", url="https://github.com/TypesettingTools/Aegisub-Motion"}, - {"a-mo.Line", version="1.0.0", url="https://github.com/TypesettingTools/Aegisub-Motion"}, - {"a-mo.Log", url="https://github.com/TypesettingTools/Aegisub-Motion"}, - "ASSInspector.Inspector", - {"YUtils", optional=true}, + name = "ASSFoundation", + version = "0.1.1", + description = "General purpose ASS processing library", + author = "line0", + url = "http://github.com/TypesettingTools/ASSFoundation", + moduleName = "l0.ASSFoundation", + feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json", + { + "l0.ASSFoundation.ClassFactory", + "aegisub.re", "aegisub.util", "aegisub.unicode", + {"l0.ASSFoundation.Common", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", + feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, + {"a-mo.LineCollection", version="1.0.1", url="https://github.com/TypesettingTools/Aegisub-Motion"}, + {"a-mo.Line", version="1.0.0", url="https://github.com/TypesettingTools/Aegisub-Motion"}, + {"a-mo.Log", url="https://github.com/TypesettingTools/Aegisub-Motion"}, + "ASSInspector.Inspector", + {"YUtils", optional=true}, } local createASSClass, re, util, unicode, Common, LineCollection, Line, Log, ASSInspector, YUtils = version:requireModules() - ``` A reference to the version record must be added as the *.version* field of your returned module for version control to work. A module should also register itself to enable circular dependency support. The *:register()* method returns your module, so the last lines of your module should look like this: ```lua - MyModule.version = version - return version:register(MyModule) - ``` -##### Providing module aliases +#### 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. @@ -212,13 +214,13 @@ Notes: --------------------------------------------- -### Namespaces and Paths ### +## Namespaces and Paths DependencyControl strictly enforces a **namespace-based file structure** for modules as well as automation macros in order to ensure there are no conflicts between scripts that happen to have the same name. Automation scripts must define their namespace in the version record whereas for modules the module name (as you would use in a `require` statement) defines the namespace. -#### Rules for a valid namespace: #### +Rules for a valid namespace: 1. contains _at least_ one dot 2. must **not** start or end with a dot @@ -227,40 +229,45 @@ Automation scripts must define their namespace in the version record whereas for 5. *should* be descriptive (this is more of a guideline) __Examples__: - * l0.ASSFoundation - * l0.ASSFoundation.Common (for a separately version-controlled 'submodule') - * l0.ASSWipe - * a-mo.LineCollection + * `l0.ASSFoundation` + * `l0.ASSFoundation.Common` (for a separately version-controlled 'submodule') + * `l0.ASSWipe` + * `a-mo.LineCollection` -#### File and Folder Structure #### +### File and Folder Structure -The namespace of your script translates into a subtree of the **user**automation directory you can use to store your files in. DepedencyControl will _not_ refuse to work with scripts that ignore this restriction, however it's designed in such a way that downloading to locations outside of your tree is **impossible** (which means your macro/module be able to use the auto-updater). +The namespace of your script translates into a subtree of the **user** automation directory you can use to store your files in: +- On Windows: `%AppData%\Aegisub\automation` +- On Linux: `~/.aegisub/automation` +- On OSX: `~/Library/Application Support/Aegisub/automation` -__Automation Scripts__ use the `?user/automation/autoload`, which has a flat file structure. You may **not** use subdirectories and your **file names must start with the namespace of your script**. +DependencyControl will _not outright_ refuse to work with scripts that ignore this restriction, however it's designed in such a way that downloading to locations outside of your tree is **impossible** (which means your package won't be able to use the auto-updater). + +**Automation Scripts** use the `?user/automation/autoload` directory, which has a flat file structure. You may **not** use subdirectories and your **file names must start with the namespace of your script**. Examples: - * l0.ASSWipe.lua - * l0.ASSWipe.Addon.moon + * `l0.ASSWipe.lua` + * `l0.ASSWipe.Addon.moon` -__Modules__ use the `?user/automation/include` folder, which has a nested file structure. To determine your _subdirectory/file base name_, the dots in your namespace are replaced with `/` (`\` in Windows terms). +**Modules** use the `?user/automation/include` folder, which has a nested file structure. To determine the base name for your main entry point file and sub-directory, the dots in your namespace are replaced with the path separator (`\` on Windows, `/` on other platforms). -__Tests__ use the `?user/automation/tests/DepUnit/modules` or `?user/automation/tests/DepUnit/macros` folder depending on whether a macro or automation is being tested and mirror the directory structure of the respective `include` and `autoload` folders. +**Tests** use the `?user/automation/tests/DepUnit/modules` or `?user/automation/tests/DepUnit/macros` folder depending on whether a macro or automation is being tested and mirror the directory structure of the respective `include` and `autoload` folders. -Our example module ASSFoundation with namespace __l0.ASSFoundation__ writes (among others) the following files: - * __?user/automation/include/l0/ASSFoundation__.lua - * __?user/automation/include/l0/ASSFoundation__/ClassFactory.lua - * __?user/automation/include/l0/ASSFoundation__/Draw/Bezier.lua - * __?user/automation/tests/modules/l0/ASSFoundation__.lua +Our example module _ASSFoundation_ with namespace `l0.ASSFoundation` writes (among others) the following files: + * `?user/automation/include/l0/ASSFoundation.lua` + * `?user/automation/include/l0/ASSFoundation/ClassFactory.lua` + * `?user/automation/include/l0/ASSFoundation/Draw/Bezier.lua` + * `?user/automation/tests/DepUnit/modules/l0/ASSFoundation.lua` --------------------------------------------- -### The Anatomy of an Updater Feed ### +## The Updater Feed -If you want DepedencyControl auto-update your script on the user's system, you'll need to supply update information in an updater feed, which is a _JSON_ file with a simple basic layout: +If you want DependencyControl auto-update your package(s) on the user's system, you'll need to supply update information in an updater feed, which is a _JSON_ file with the following layout: *(`//` denotes a comment explaining the property above)* -`````javascript +```json { "dependencyControlFeedFormatVersion": "0.3.0", // The version of the feed format. The current version is 0.3.0, don't touch this until further notice. @@ -272,7 +279,7 @@ If you want DepedencyControl auto-update your script on the user's system, you'l "a-mo": "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json", "ASSFoundation": "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json" }, - // A hashtable of known feed URLs. Can be referenced with @{feed:name} and will be used to discover other repositories the user can install automation scripts and modules from. At the very least this should contain the repo URLs for the required modules in your repo, but may be used to advertise other unrelated repos you trust. + // A hash table of known feed URLs. Can be referenced with @{feed:name} and will be used to discover other repositories the user can install automation scripts and modules from. At the very least this should contain the repo URLs for the required modules in your repo, but may be used to advertise other unrelated repos you trust. "baseUrl": "https://github.com/TypesettingTools/line0-Aegisub-Scripts", // baseUrl is a template variable that can be referenced in other string fields of the template. It's useful when you have several scripts which all have their documentation hosted on the same site (so they start with the same URL). For more Information about templates, see the section below. "url": "@{baseUrl}", @@ -282,164 +289,165 @@ If you want DepedencyControl auto-update your script on the user's system, you'l "macros": { // the section where all automation scripts tracked by this feed go. The key for each value is the namespace of the respective script. Below this level, this namespace is available as the @{namespace} and @{namespacePath} template variable - "l0.ASSWipe": { ... }, - "l0.Nudge": { ... } + "l0.ASSWipe": { /* ... */ }, + "l0.Nudge": { /* ... */ } }, "modules": { // Your modules go here. If your feed doesn't track any modules, you may omit this section (same goes for the macros object) - "l0.ASSFoundation": { ... } + "l0.ASSFoundation": { /* ... */ } } - -````` +``` An automation script or module object looks like this: -````javascript +```json "l0.ASSWipe": { - "url": "@{baseUrl}#@{namespace}", - "author": "line0", - "name": "ASSWipe", - "description": "Performs script cleanup, removes unnecessary tags and lines.", - // These script information fields should be identical to the values defined in your - // DepedencyControl version record. - "channels": { - // a list of update channels available for your script (think release, beta and alpha). - // The key is a channel name of your choice, but should make sense to the user picking one. - "master": { - // This example only defines one channel, which is set up to track - // the HEAD of a GitHub repository. - "version": "0.1.3", - // The current script version served in this channel. - // Must be identical to the one in the version record. - "released": "2015-02-26", - // Release date of the current script version (UTC/ISO 8601 format) - "default": true, - // Marks this channel as the default channel in case the user doesn't have picked a specific one. - // Must be set to true for **exactly** one channel in the list. - "platforms": ["Windows-x86", "Windows-x64", "OSX-x64"] - // Optional: A list of platforms you serve builds for. You should omit this property for regular scripts - // and modules that use only Lua/Moonscript and no binaries. If this property is absent, - // the platform check will be skipped. The platform names are derived from the output of - // ffi.os()-ffi.arch() in luajit. - "files": [ - // A list of files installed by your script. - { - "name": ".lua", - // the file name relative to the path assigned to the script by your namespace choice - // (see 3. Namespaces and Paths for more information). Available as the @{fileName} template variable - // for use in the url field below. - "url": "@{fileBaseUrl}@{fileName}", - // URL from which the **raw** file can be downloaded from (no archives, no javascript - // redirects, etc...). In this case the templates expand to - // "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/l0.ASSWipe.lua" - "sha1": "A7BD1C7F0E776BA3010B1448F22DE6528F73B077" - // The SHA-1 hash of the file being currently served under that url. Will be checked - // against the downloaded file, so it must always be present and valid or the update process - // will fail on the user's end. - }, - { - "name": ".lua", - "type": "test", - // Optional, defaults to "script". Specify "test" to denote a unit test. - // Currently only "script" and "test" are available, unknown script types will be skipped. - "url": "@{fileBaseUrl}.Tests.lua", - "sha1": "27745AB9CF04A840CF3454050CA9D38FA345CEBB" - }, - { - "name": ".Helper.dll", - "url": "@{fileBaseUrl}@{fileName}", - "sha1": "0B4E0511116355D4A11C2EC75DF7EEAD0E14DE9F" - "platform": "Windows-x86" - // Optional. When this property is present, the file will only be downloaded to the users - // computer if his platform matches to this value. - } - ], - "requiredModules": [ - // an exhaustive list of modules required by this script. Must be identical to the required - // module entries in your DepdencyControl record, but you may not use short style here. - // (see 2. Usage for Automation Scripts for more information) - { - "moduleName": "a-mo.LineCollection", - "name": "Aegisub-Motion (LineCollection)", - "url": "https://github.com/torque/Aegisub-Motion", - "version": "1.0.1", - "feed": "@{feed:a-mo}" - }, - { - "moduleName": "l0.ASSFoundation", - "name": "ASSFoundation", - "url": "https://github.com/TypesettingTools/ASSFoundation", - "version": "0.1.1", - "feed": "@{feed:ASSFoundation}" - }, - { - "moduleName": "aegisub.util" - }, - ] + "url": "@{baseUrl}#@{namespace}", + "author": "line0", + "name": "ASSWipe", + "description": "Performs script cleanup, removes unnecessary tags and lines.", + // These script information fields should be identical to the values defined in your + // DependencyControl version record. + "channels": { + // a list of update channels available for your script (think release, beta and alpha). + // The key is a channel name of your choice, but should make sense to the user picking one. + "master": { + // This example only defines one channel, which is set up to track + // the HEAD of a GitHub repository. + "version": "0.1.3", + // The current script version served in this channel. + // Must be identical to the one in the version record. + "released": "2015-02-26", + // Release date of the current script version (UTC/ISO 8601 format) + "default": true, + // Marks this channel as the default channel in case the user doesn't have picked a specific one. + // Must be set to true for **exactly** one channel in the list. + "platforms": ["Windows-x86", "Windows-x64", "OSX-x64"] + // Optional: A list of platforms you serve builds for. You should omit this property for regular scripts + // and modules that use only Lua/Moonscript and no binaries. If this property is absent, + // the platform check will be skipped. The platform names are derived from the output of + // ffi.os()-ffi.arch() in luajit. + "files": [ + // A list of files installed by your script. + { + "name": ".lua", + // the file name relative to the path assigned to the script by your namespace choice + // (see 3. Namespaces and Paths for more information). Available as the @{fileName} template variable + // for use in the url field below. + "url": "@{fileBaseUrl}@{fileName}", + // URL from which the **raw** file can be downloaded from (no archives, no javascript + // redirects, etc...). In this case the templates expand to + // "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/l0.ASSWipe.lua" + "sha1": "A7BD1C7F0E776BA3010B1448F22DE6528F73B077" + // The SHA-1 hash of the file being currently served under that url. Will be checked + // against the downloaded file, so it must always be present and valid or the update process + // will fail on the user's end. + }, + { + "name": ".lua", + "type": "test", + // Optional, defaults to "script". Specify "test" to denote a unit test. + // Currently only "script" and "test" are available, unknown script types will be skipped. + "url": "@{fileBaseUrl}.Tests.lua", + "sha1": "27745AB9CF04A840CF3454050CA9D38FA345CEBB" + }, + { + "name": ".Helper.dll", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "0B4E0511116355D4A11C2EC75DF7EEAD0E14DE9F", + "platform": "Windows-x86" + // Optional. When this property is present, the file will only be downloaded to the users + // computer if his platform matches to this value. } - }, - "changelog": { - // a change log that allows users to see what's new in this and previous versions. The changelog - // is shared between all channels. Only the entries with a version number equal or below - // the version the user just updated to will be displayed. - "0.1.0": [ - "Sync with ASSFoundation changes", - // one entry for each line - "Start versioning with DependencyControl" - ], - "0.1.3": [ - "Enabled auto-update using DependencyControl", - "Changed config file to \\config\\l0.ASSWipe.json (rename ASSWipe.json to restore your existing configuration)", - "DependencyControl compatibility fixes" - ] - } + ], + "requiredModules": [ + // an exhaustive list of modules required by this script. Must be identical to the required + // module entries in your DependencyControl record, but you may not use short style here. + // (see 2. Usage for Automation Scripts for more information) + { + "moduleName": "a-mo.LineCollection", + "name": "Aegisub-Motion (LineCollection)", + "url": "https://github.com/torque/Aegisub-Motion", + "version": "1.0.1", + "feed": "@{feed:a-mo}" + }, + { + "moduleName": "l0.ASSFoundation", + "name": "ASSFoundation", + "url": "https://github.com/TypesettingTools/ASSFoundation", + "version": "0.1.1", + "feed": "@{feed:ASSFoundation}" + }, + { + "moduleName": "aegisub.util" + }, + ] } -```` + }, + "changelog": { + // a change log that allows users to see what's new in this and previous versions. The changelog + // is shared between all channels. Only the entries with a version number equal or below + // the version the user just updated to will be displayed. + "0.1.0": [ + "Sync with ASSFoundation changes", + // one entry for each line + "Start versioning with DependencyControl" + ], + "0.1.3": [ + "Enabled auto-update using DependencyControl", + "Changed config file to \\config\\l0.ASSWipe.json (rename ASSWipe.json to restore your existing configuration)", + "DependencyControl compatibility fixes" + ] + } +} +``` -#### Template Variables #### +Full _JSON Schema_ documents (which you can use to validate your feeds) are provided for the following feed versions: -To make maintaining an update feed easier, you can use several template variables that will be expanded when used inside string values (but **not** Keys). +- [v0.3.0](./schemas/feed/v0.3.0.json) (also validates legacy _v0.2.0_ feeds) -__Regular Variables:__ These reference a specific key or value and are available at the same depth and further down the tree from the point on where they were created. +### Template Variables + +To make maintaining an update feed easier, you can use several template variables that will be expanded when used inside string values (but **not** keys). + +**Regular Variables**: These reference a specific key or value and are available at the same depth and further down the tree from the point on where they were created. Variables extracted at the **same depth** are expanded in a specific order. As a consequence only references to variables of lower order are expanded in values that are assigned to a variable themselves. _Depth 1:_ Feed Information - 1. __feedName__: The name of the feed - 2. __baseUrl__: The baseUrl field - 3. __feed:###__: A reference to a feed URL in the knownFeeds table + 1. `@{feedName}`: The name of the feed + 2. `@{baseUrl}`: The baseUrl field + 3. `@{feed:}`: A reference to a feed URL in the knownFeeds table _Depth 3:_ Script Information - 1. __namespace__: the script namespace - 2. __namespacePath__: the script namespace with all `.` replaced by `/` - 3. scriptName: the script name + 1. `@{namespace}`: the script namespace + 2. `@{namespacePath}`: the script namespace with all `.` replaced by `/` + 3. `@{scriptName}`: the script name _Depth 5:_ Version Information - 1. __channel__: the channel name of this version record - 2. __version__: the version number as a SemVer string + 1. `@{channel}`: the channel name of this version record + 2. `@{version}`: the version number as a SemVer string _Depth 7:_ File Information - 1. __platform__: the platform defined for this file, otherwise an empty string - 2. __fileName__: the file name - + 1. `@{platform}`: the platform defined for this file, otherwise an empty string + 2. `@{fileName}`: the file name -__"Rolling" Variables:__ These variables can be defined at any depth in the JSON tree and are continuously expanded using the variables available. You can reference a rolling variable in itself, which will substitute the template for the contents the variable had at the parent-level. +**"Rolling" Variables**: These variables can be defined at any depth in the JSON tree and are continuously expanded using the variables available. You can reference a rolling variable in itself, which will substitute the template for the contents the variable had at the parent-level. -Right now there's only one such variable: __fileBaseUrl__, which you can use to construct the URL to a file using the template variables available. +Right now there's only one such variable: `@{fileBaseUrl}`, which you can use to construct the URL to a file using the template variables available. -For an example to serve updates from the HEAD of a GitHub repository, see [here](https://github.com/TypesettingTools/line0-Aegisub-Scripts/blob/master/DependencyControl.json). An example that shows a feed making use of tagged releases is [also available](https://github.com/TypesettingTools/ASSFoundation/blob/master/DependencyControl.json). +For an example to serve updates from the HEAD of a GitHub repository main branch, see [here](https://github.com/TypesettingTools/line0-Aegisub-Scripts/blob/master/DependencyControl.json). An example that shows a feed making use of tagged releases is [also available](https://github.com/TypesettingTools/ASSFoundation/blob/master/DependencyControl.json). - --------------------------------------------- -### Reference ### +## Reference This section is currently both incomplete and outdated. Sorry about that. -#### DependencyControl #### +### DependencyControl -__DependencyControl{*tbl* [requiredModules]={}, *str* :name=script_name, *str* :description=script_description, *str* :author=script_author, *str* :url, *str* :version, *str* :moduleName, *str* [:configFile], *string* [:namespace]} --> *obj* DependecyControlRecord__ +__DependencyControl{*tbl* [requiredModules]={}, *str* :name=script_name, *str* :description=script_description, *str* :author=script_author, *str* :url, *str* :version, *str* :moduleName, *str* [:configFile], *string* [:namespace]} --> *obj* DependencyControlRecord__ -The constructor for a DepedencyControl record. Uses the table-based signature. +The constructor for a DependencyControl record. Uses the table-based signature. __Arguments:__ * _requiredModules_: the first and only unnamed argument. Contains all required modules, which may be either a single string for a non-version-controlled requirement or a table with the following fields: @@ -457,7 +465,7 @@ __Arguments:__ * _feed_: The update feed for your script. * _configFile_: Configuration file base name used by the script. Defaults to the namespace. Used for configuration services and script management purposes. -##### Methods ##### +#### Methods __:checkVersion(*str/num* version, *str* [precision = "patch"]) --> *bool* moduleUpToDate, *str* error__ Returns true if the version number of the record is greater than or equal to __version__. Reduce the __precision__ to `minor` or `major` to also return true for lower patch or minor versions respectively. If the version can't be parsed it returns nil and and error message. @@ -470,7 +478,7 @@ __:getConfigFileName() --> *str* fileName__ Returns a full path to the config file proposed for this script by DependencyControl. Uses the configFile argument passed to the constructor which defaults to the script namespace. The path is subject to user configuration and defaults to "?user\config". The file ending is always .json, because why would you use any other format? -The rationale for this function is to keep all macro and module configuration files neatly in one spot and make them discoverable for other scripts (through the DepedencyControl config file). +The rationale for this function is to keep all macro and module configuration files neatly in one spot and make them discoverable for other scripts (through the DependencyControl config file). __:getConfigHandler([defaults], [section], [noLoad]) => *obj* ConfigHandler__ @@ -494,7 +502,7 @@ Generates and returns a full path to the registered config file name for the mod __:loadConfig(*bool* [importRecord], *bool* [forceReloadGlobal]) --> *bool* shouldWriteConfig, *bool* firstInit__ -Loads global DependencyControl and per-script configuration from the DepedencyControl configuration file. If __importRecord__ is true, the version record information of a DependencyControl record will be (temporarily) overwritten by the values contained in the configuration file. +Loads global DependencyControl and per-script configuration from the DependencyControl configuration file. If __importRecord__ is true, the version record information of a DependencyControl record will be (temporarily) overwritten by the values contained in the configuration file. Global configuration is only loaded on first run or if __forceReloadGlobal__ is true. The first return result indicates there are changes to be written to the config file, the second result returns true if the config file was only just created. _Intended for internal use._ @@ -505,7 +513,7 @@ Loads and returns single module and only errors out in case of module errors. In __:moveFile(*str* src, *str* dest) --> *bool* success, *str* error__ -Moves a file from __source__ to __destiantion__ (where both are full file names). Returns true on success or false and error message on failure. +Moves a file from __source__ to __destination__ (where both are full file names). Returns true on success or false and error message on failure. __:register(*tbl* selfRef, extraUnitTestArgs...) --> *tbl* selfRef__ @@ -532,7 +540,7 @@ For the other arguments, please refer to the [aegisub.register_macro](http://doc __:registerMacros(*tbl* macros, *bool|string* [submenuDefault=true])__ Registers multiple macros, where __macros__ is a list of tables containing the arguments to a __:registerMacro()__ call for each automation menu entry. a single macro using script name and description by default. -Use __submenuDefault__ to specify a submenu all macros will be placed in unless overriden on a per-macro basis. Defaults to `true` which causes the automation script name to be used as the submenu name. +Use __submenuDefault__ to specify a submenu all macros will be placed in unless overridden on a per-macro basis. Defaults to `true` which causes the automation script name to be used as the submenu name. __:registerTests(unitTestArgs...)__ @@ -550,9 +558,9 @@ __:writeConfig(*bool* [writeLocal=true], *bool* [writeGlobal=true], *bool* [conc Writes __global__ and per-module __local__ configuration. If __concert__ is true, concerted writing will be used to update the configuration of all DependencyControl hosted by any given macro/environment at once. See ConfigHandler documentation for more information. _Intended for internal use._ -#### Updater ##### +### Updater -##### Methods ##### +#### Methods __:getUpdaterErrorMsg(*int* [code], *str* targetName, ...) --> *str* errorMsg__ @@ -568,7 +576,7 @@ __:getUpdaterLock(*bool* [doWait], *int* [waitTimeout=(user config)]) --> *bool* Locks the updater to the current macro/environment. Since all automation scripts load in parallel we have to make sure multiple automation scripts don't all update/fetch the same dependencies at once multiple times. The solution is to only let one updater operate at a time. The others will wait their turn and recheck if their required modules were fetched in the meantime. -If __doWait__ is true, the function will wait until the updater is unlocked or __waitTimeout__ has passed. It will then get the lock and return true. If __doWait__ is false, the function will return immediately (true on success, false if another updater has the lock). _Intendend for internal use_. +If __doWait__ is true, the function will wait until the updater is unlocked or __waitTimeout__ has passed. It will then get the lock and return true. If __doWait__ is false, the function will return immediately (true on success, false if another updater has the lock). _Intended for internal use_. __:releaseUpdaterLock()__ @@ -582,29 +590,27 @@ By default, the updater will process all suitable feeds until one feed confirms Returns a result code (0: up-to-date, 1: update performed, <=-1: error) and extended error information which can be fed into _:getUpdaterErrorMsg()_ to get a descriptive error message. -#### Logger #### +### Logger tbd -#### ConfigHandler #### +### ConfigHandler tbd -#### FileOps #### +### FileOps tbd -#### UnitTestSuite #### +### UnitTestSuite Reference documentation for the UnitTestSuite module is available in the [source code](https://github.com/TypesettingTools/DependencyControl/blob/master/modules/DependencyControl/UnitTestSuite.moon#L760) -#### UpdateFeed #### +### UpdateFeed tbd ----------------------------------- - -### Running the Test Suite ### +## DependencyControl Testing DependencyControl ships a headless test runner (`run-tests.lua`) that executes the full unit test suite from the command line — locally or in CI — **without** an Aegisub process. An @@ -612,20 +618,20 @@ test suite from the command line — locally or in CI — **without** an Aegisub stand in for the host application, and JSON is vendored (dkjson), so the only external dependencies are LuaJIT and two LuaRocks modules. -#### Prerequisites #### +### Prerequisites - * _LuaJIT_ on your `PATH`, built with `DLUAJIT_ENABLE_LUA52COMPAT` - * _LuaRocks_, configured for Lua v5.1, which _LuaJIT_ is ABI-compatible with. You may have to select the Lua version explicitly via `luarocks --lua-version=5.1` - * The [moonscript](https://luarocks.org/modules/leafo/moonscript) and [luafilesystem](https://luarocks.org/modules/hisham/luafilesystem) rocks, installed into that 5.1 tree: + - _LuaJIT_ on your `PATH`, built with `DLUAJIT_ENABLE_LUA52COMPAT` + - _LuaRocks_, configured for Lua v5.1, which _LuaJIT_ is ABI-compatible with. You may have to select the Lua version explicitly via `luarocks --lua-version=5.1` + - The [moonscript](https://luarocks.org/modules/leafo/moonscript) and [LuaFileSystem](https://luarocks.org/modules/hisham/luafilesystem) rocks, installed into that 5.1 tree: ```sh luarocks --lua-version=5.1 install moonscript luarocks --lua-version=5.1 install luafilesystem ``` - * Your `LUA_PATH` / `LUA_CPATH` must let `luajit` find the LuaRocks-installed modules (`luarocks --lua-version=5.1 path --bin` prints the correct values). + - Your `LUA_PATH` / `LUA_CPATH` must let `luajit` find the LuaRocks-installed modules (`luarocks --lua-version=5.1 path --bin` prints the correct values). -#### Running #### +### Running From the repository root: @@ -641,7 +647,7 @@ your system temp directory, rather than touching your real Aegisub configuration path token (`?user`, `?temp`, ...) gets its own subdirectory there. Set `DEPCTRL_TEMP_DIR` to choose a different base directory for that workspace. -#### Test report #### +### Test report After running, the suite writes a [CTRF](https://ctrf.io) report — a JSON test-result format understood by ready-made CI reporters. By default it lands at `./ctrf/DependencyControl.json` From 4d4dfe02fd4fd985eb1197bceb05a2882f0bc2cc Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 2 Jun 2026 23:10:47 +0200 Subject: [PATCH 37/47] build: add cspell settings and dictionaries --- .cspell/domain-specific.txt | 22 ++++++++++++++++++++++ .cspell/ffi.txt | 35 +++++++++++++++++++++++++++++++++++ .cspell/lua.txt | 34 ++++++++++++++++++++++++++++++++++ cspell.json | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 .cspell/domain-specific.txt create mode 100644 .cspell/ffi.txt create mode 100644 .cspell/lua.txt create mode 100644 cspell.json diff --git a/.cspell/domain-specific.txt b/.cspell/domain-specific.txt new file mode 100644 index 0000000..219745b --- /dev/null +++ b/.cspell/domain-specific.txt @@ -0,0 +1,22 @@ +Aegisub +BadMutex +copas +CPATH +CTRF +DLUAJIT +DepCtrl +dkjson +FileOps +headlessly +leafo +libaegisub +luafilesystem +luajit +luarocks +luasocket +mimetypes +moonbase +moonc +moonpath +pegasus +PreciseTimer diff --git a/.cspell/ffi.txt b/.cspell/ffi.txt new file mode 100644 index 0000000..2490851 --- /dev/null +++ b/.cspell/ffi.txt @@ -0,0 +1,35 @@ +CONNECTTIMEOUT +CURLINFO +CURLMOPT +CURLMSG +CURLOPT +ENOENT +ENOTDIR +errornum +FAILONERROR +fclose +FOLLOWLOCATION +fopen +getinfo +gettime +getpid +nfds +NOPROGRESS +nsec +numfds +oflag +O_CREAT +pollfd +setopt +setx +strerror +syscall +timespec +trywait +uintptr +usec +usleep +USERAGENT +wchar +WinINet +WRITEDATA diff --git a/.cspell/lua.txt b/.cspell/lua.txt new file mode 100644 index 0000000..1e7defa --- /dev/null +++ b/.cspell/lua.txt @@ -0,0 +1,34 @@ +__newindex +addserver +addthread +chdir +collectgarbage +currentdir +finalizers +cdef +getenv +getmetatable +getsockname +gmatch +gsub +ipairs +lshift +luafilesystem +luajit +luajson +luarocks +metatable +metatables +newproxy +pcall +pcalls +randomseed +rawget +rshift +setmetatable +settimeout +setvbuf +tonumber +tostring +varargs +xpcall diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..e53e20c --- /dev/null +++ b/cspell.json @@ -0,0 +1,35 @@ +{ + "dictionaryDefinitions": [ + { + "name": "lua", + "path": "./.cspell/lua.txt", + "scope": "workspace", + "addWords": true + }, + { + "name": "ffi", + "path": "./.cspell/ffi.txt", + "scope": "workspace", + "addWords": true + }, + { + "name": "domain-specific", + "path": "./.cspell/domain-specific.txt", + "scope": "workspace", + "addWords": true + } + ], + "dictionaries": ["domain-specific"], + "languageSettings": [ + { + "languageId": "lua", + "dictionaries": ["lua", "ffi"] + }, + { + "languageId": "moonscript", + "dictionaries": ["lua", "ffi"] + } + ], + "words": ["moonscript"], + "ignorePaths": [".cspell/**"] +} From 5c08fc9c78146f41f559a8b8cf2da9cc3cf21f3d Mon Sep 17 00:00:00 2001 From: line0 Date: Thu, 4 Jun 2026 19:59:43 +0200 Subject: [PATCH 38/47] feat: add bundler script; CLI zip archiver bindings --- .github/workflows/test.yml | 2 +- .gitignore | 2 + depctrl.lua | 314 +++++++++++++++++++++ modules/DependencyControl/Common.moon | 14 + modules/DependencyControl/UpdateFeed.moon | 29 +- modules/DependencyControl/Updater.moon | 8 +- modules/DependencyControl/ZipArchiver.moon | 165 +++++++++++ run-tests.lua | 121 -------- 8 files changed, 517 insertions(+), 138 deletions(-) create mode 100644 depctrl.lua create mode 100644 modules/DependencyControl/ZipArchiver.moon delete mode 100644 run-tests.lua diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5bb2421..08ec8ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,7 +37,7 @@ jobs: - name: Run tests timeout-minutes: 5 - run: lua run-tests.lua + run: lua depctrl.lua test - name: Publish test report uses: ctrf-io/github-test-reporter@v1 diff --git a/.gitignore b/.gitignore index 82085e0..ffd4d42 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /ctrf/*.json +/dist/ +/DependencyControl-*.zip diff --git a/depctrl.lua b/depctrl.lua new file mode 100644 index 0000000..0263d9f --- /dev/null +++ b/depctrl.lua @@ -0,0 +1,314 @@ +#!/usr/bin/env luajit +-- DependencyControl CLI launcher. +-- +-- Usage: luajit depctrl.lua [args...] +-- +-- luajit depctrl.lua test [ctrf-report-path] +-- Run the unit test suite. The optional argument overrides the CTRF report +-- output path (default: ctrf/DependencyControl.json next to this script). +-- Exit code 0 = all tests pass, 1 = failures. +-- +-- luajit depctrl.lua bundle +-- Build a dist/ release bundle by copying every file listed in +-- DependencyControl.json to the path derived from its expanded download URL. +-- dist/ is cleaned first. Exit code 0 = success, 1 = one or more warnings. + +local ffi = require "ffi" +local lfs = require "lfs" +require "moonscript" +local moonbase = require "moonscript.base" + +-- ── Path utilities ──────────────────────────────────────────────────────────── + +local isWindows = ffi.os == "Windows" +local pathSep = isWindows and "\\" or "/" + +local function dirname(path) + return (path or ""):match("^(.*)[/\\][^/\\]*$") or "." +end + +local function isAbsolute(path) + return path:match("^%a:[/\\]") ~= nil -- C:\... + or path:match("^[/\\]") ~= nil -- /... or \... +end + +local function fileExists(path) + local f = io.open(path, "r") + if f then f:close(); return true end + return false +end + +-- ── Resolve the launcher directory ─────────────────────────────────────────── +-- Made absolute up-front so nothing downstream can be confused by CWD changes. + +local launcherDir = dirname(arg and arg[0]) +if launcherDir == "." then + launcherDir = lfs.currentdir() +elseif not isAbsolute(launcherDir) then + launcherDir = lfs.currentdir() .. pathSep .. launcherDir +end +local modulesDir = launcherDir .. pathSep .. "modules" + +-- ── Module searcher ─────────────────────────────────────────────────────────── +-- Maps l0.* names onto the repo's modules/ tree, loading .moon or .lua sources. +-- Appended last so stock searchers (lfs, ffi, moonscript) still take precedence; +-- bare aliases like "json" are resolved by DepCtrl's ModuleProvider once loaded. + +local function l0ModuleSearcher(name) + local sub = name:match("^l0%.(.+)$") + if not sub then return nil end + + local relPath = sub:gsub("%.", pathSep) + local base = modulesDir .. pathSep .. relPath + local candidates = { + { path = base .. ".moon", moon = true }, + { path = base .. ".lua", moon = false }, + { path = base .. pathSep .. "init.moon", moon = true }, + { path = base .. pathSep .. "init.lua", moon = false }, + } + for _, c in ipairs(candidates) do + if fileExists(c.path) then + local chunk, err = (c.moon and moonbase.loadfile or loadfile)(c.path) + if not chunk then error(err) end + return chunk + end + end + return "\n\tno l0 module file for '" .. name .. "' under " .. modulesDir +end +table.insert(package.loaders or package.searchers, l0ModuleSearcher) + +-- ── Aegisub shims ───────────────────────────────────────────────────────────── + +local shims = require "l0.AegisubShims" +local aegisub = shims.aegisub -- pulled into local scope; global is set by the shim for sub-modules + +-- ── Shared: workspace + DepCtrl bootstrap ──────────────────────────────────── + +local function setupDepCtrl(workspacePrefix) + local tempBase = shims.getPathToken("temp") + local workspace = tempBase .. pathSep .. (workspacePrefix .. "-%x"):format(os.time() % 0x100000) + for _, token in ipairs({ "user", "local", "data", "temp" }) do + shims.setPathToken(token, workspace .. pathSep .. token) + end + + local FileOps = require "l0.DependencyControl.FileOps" + FileOps.mkdir("?temp", false, true) + FileOps.mkdir("?user/log", false, true) + + -- Disable the self-updater so loading DepCtrl does not trigger a network + -- fetch of its own feed (slow, flaky, pointless outside Aegisub). + local globalConfigPath = aegisub.decode_path("?user/config/l0.Record.json") + FileOps.mkdir(globalConfigPath, true, true) + do + local json = require "l0.dkjson" + local h = assert(io.open(globalConfigPath, "w")) + h:write(json.encode({ config = { updaterEnabled = false } })) + h:close() + end + + return require "l0.DependencyControl" +end + +-- ── Command dispatch ────────────────────────────────────────────────────────── + +local cmd = arg[1] + +-- ─── test ───────────────────────────────────────────────────────────────────── +if cmd == "test" then + local DepCtrl = setupDepCtrl("depctrl-tests") + local suite = require "l0.DependencyControl.Tests" + + suite:import(DepCtrl) + local success = suite:run() + + local reportPath = arg[2] or (launcherDir .. pathSep .. "ctrf" .. pathSep .. "DependencyControl.json") + if not isAbsolute(reportPath) then + reportPath = lfs.currentdir() .. pathSep .. reportPath:gsub("^%.[/\\]", "") + end + local wrote, writeErr = suite:writeResults(reportPath) + + io.stderr:write(wrote and ("\nWrote CTRF report to " .. reportPath .. "\n") + or ("\nWarning: couldn't write CTRF report: " .. tostring(writeErr) .. "\n")) + io.stderr:write(success and "\nAll DependencyControl tests passed.\n" + or "\nDependencyControl tests FAILED.\n") + os.exit(success and 0 or 1) + +-- ─── bundle ─────────────────────────────────────────────────────────────────── +elseif cmd == "bundle" then + setupDepCtrl("depctrl-bundle") + + local Common = require "l0.DependencyControl.Common" + local FileOps = require "l0.DependencyControl.FileOps" + local UpdateFeed = require "l0.DependencyControl.UpdateFeed" + local ZipArchiver = require "l0.DependencyControl.ZipArchiver" + local feedPath = launcherDir .. pathSep .. "DependencyControl.json" + + -- Load and expand the feed without touching the network. + local feed = UpdateFeed(feedPath, false) + local ok, err = feed:loadFile(feedPath) + if not ok then + io.stderr:write("Error loading feed: " .. tostring(err) .. "\n") + os.exit(1) + end + + local feedFileBaseUrl = feed.data.fileBaseUrl or "" + if feedFileBaseUrl == "" then + io.stderr:write("Error: feed has no fileBaseUrl — cannot determine source paths\n") + os.exit(1) + end + + -- ── Clean and recreate dist/ ────────────────────────────────────────────── + -- All managed file operations go through DependencyControl's own FileOps so + -- the launcher stays a thin wrapper around the library it ships. + + local distDir = launcherDir .. pathSep .. "dist" + FileOps.remove(distDir, true) + FileOps.mkdir(distDir, false, true) + + -- ── Copy files ──────────────────────────────────────────────────────────── + -- Source path: the file's expanded URL has the feed-level fileBaseUrl prefix + -- stripped to a versioned path (e.g. "v0.7.0-alpha/modules/Foo.moon"); dropping + -- the leading version/channel segment yields the repo-relative source path. + -- Destination: the file's install layout, derived from its namespace via + -- Common.getFileDeployPath (autoload/ for macros, include/ for modules, tests/DepUnit/… + -- for test files), so dist/ mirrors an Aegisub automation directory and the + -- bundle is a drop-in extract. + + local function escapePat(s) + return (s:gsub("([%.%+%-%*%?%[%]%^%$%(%)%%])", "%%%1")) + end + local baseUrlPat = "^" .. escapePat(feedFileBaseUrl) .. "(.+)$" + -- Install paths from Common.getFileDeployPath are absolute under ?user/automation; strip + -- that root to get the path relative to dist/. Normalize separators to '/'. + local autoRoot = aegisub.decode_path("?user/automation"):gsub("\\", "/") + + local fileCount, warnCount = 0, 0 + + for _, section in ipairs({ "macros", "modules" }) do + local pkgs = feed.data[section] + if not pkgs then goto nextSection end + local scriptType = section == "macros" and Common.ScriptType.Automation + or Common.ScriptType.Module + + for namespace, pkg in pairs(pkgs) do + for channelName, channel in pairs(pkg.channels or {}) do + for _, file in ipairs(channel.files or {}) do + local url = file.url + if not url then + io.stderr:write((" warn: %s/%s/%s has no url\n") + :format(namespace, channelName, tostring(file.name))) + warnCount = warnCount + 1 + goto nextFile + end + + -- Source: strip the feed base URL, then the leading version/channel segment. + local afterBase = url:match(baseUrlPat) + if not afterBase then + io.stderr:write((" warn: URL not under feedFileBaseUrl, skipping:\n %s\n"):format(url)) + warnCount = warnCount + 1 + goto nextFile + end + + local relPath = afterBase:match("^[^/]+/(.+)$") + if not relPath then + io.stderr:write((" warn: cannot strip version prefix from: %s\n"):format(afterBase)) + warnCount = warnCount + 1 + goto nextFile + end + + local srcPath = launcherDir .. pathSep .. relPath:gsub("/", pathSep) + if not fileExists(srcPath) then + io.stderr:write((" warn: source not found: %s\n"):format(srcPath)) + warnCount = warnCount + 1 + goto nextFile + end + + -- Destination: install layout relative to dist/. + local installRel = Common:getFileDeployPath(namespace, scriptType, file.name, file.type or "script") + :gsub("\\", "/"):sub(#autoRoot + 2) + local dstPath = distDir .. pathSep .. installRel:gsub("/", pathSep) + + FileOps.mkdir(dstPath, true, true) -- ensure the target's parent dir exists + local copied, copyErr = FileOps.copy(srcPath, dstPath) + if copied then + io.stdout:write((" %s → dist/%s\n"):format(relPath, installRel)) + fileCount = fileCount + 1 + else + io.stderr:write((" error copying %s: %s\n"):format(relPath, tostring(copyErr))) + warnCount = warnCount + 1 + end + + ::nextFile:: + end + end + end + ::nextSection:: + end + + -- ── Create the zip archive ───────────────────────────────────────────────── + -- Named DependencyControl-v; when HEAD is not on a tag, a + -- --g suffix is appended (git-describe style). + + local function defaultChannelVersion(pkg) + local fallback + for _, ch in pairs(pkg.channels or {}) do + fallback = fallback or ch.version + if ch.default then return ch.version end + end + return fallback + end + + local mainPkg = feed.data.modules and feed.data.modules["l0.DependencyControl"] + local mainVersion = mainPkg and defaultChannelVersion(mainPkg) + if not mainVersion then + io.stderr:write("Error: couldn't determine l0.DependencyControl version from feed\n") + os.exit(1) + end + + local function git(args) + local h = io.popen(('git -C "%s" %s 2>&1'):format(launcherDir, args)) + if not h then return nil end + local out = (h:read("*a") or ""):gsub("%s+$", "") + local success = h:close() + return success and out ~= "" and out or nil + end + + local suffix = "" + if not git("describe --exact-match --tags HEAD") then -- HEAD is not on a tag + local branch = git("rev-parse --abbrev-ref HEAD") or "unknown" + local hash = git("rev-parse --short=7 HEAD") or "0000000" + suffix = ("-%s-g%s"):format(branch, hash) + end + + local zipName = ("DependencyControl-v%s%s.zip"):format(mainVersion, suffix) + local zipPath = launcherDir .. pathSep .. zipName + + -- Archive the whole dist/ tree via DependencyControl's own ZipArchiver, which + -- uses each platform's stock tooling and emits spec-compliant forward-slash + -- entries (per-platform details live in the module). + local zipOk = false + if fileCount > 0 then + local success, archiveErr = ZipArchiver(zipPath):addDirectory(distDir):write() + if success then + zipOk = true + else + io.stderr:write("Warning: archive creation failed: " .. tostring(archiveErr) .. "\n") + end + end + + local status = fileCount > 0 and "Bundle complete" or "Bundle produced no files" + io.stdout:write(("\n%s: %d file(s) copied, %d warning(s) → %s\n") + :format(status, fileCount, warnCount, distDir)) + if zipOk then + io.stdout:write(("Archive: %s\n"):format(zipPath)) + end + os.exit(warnCount > 0 and 1 or 0) + +-- ─── usage ──────────────────────────────────────────────────────────────────── +else + io.stderr:write(("Usage: luajit %s [args...]\n"):format(arg[0] or "depctrl.lua")) + io.stderr:write("Commands:\n") + io.stderr:write(" test [ctrf-report-path] Run the unit test suite\n") + io.stderr:write(" bundle Build the dist/ release bundle\n") + os.exit(1) +end diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon index 48ed94a..4837253 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -161,6 +161,20 @@ class DependencyControlCommon @testDir = {aegisub.decode_path("?user/automation/tests/DepUnit/macros"), aegisub.decode_path("?user/automation/tests/DepUnit/modules")} + --- Resolves the install path of a packaged file from its owning script's namespace, + -- mirroring the layout the Updater installs into: automation scripts go to the + -- autoload dir, modules to the include dir (under their namespace path), and test + -- files to the matching DepUnit test dir. + -- @param namespace string + -- @param scriptType number a ScriptType value + -- @param fileName string the file's feed name (e.g. ".moon", "/Common.moon") + -- @param[opt="script"] fileType string "script" or "test" + -- @return string path + @getFileDeployPath = (namespace, scriptType, fileName, fileType = "script") => + subDir = scriptType == @ScriptType.Module and (namespace\gsub "%.", "/") or namespace + baseDir = fileType == "test" and @testDir[scriptType] or @automationDir[scriptType] + return "#{baseDir}/#{subDir}#{fileName}" + --- Deep equality comparison. Tables compared recursively; other types use ==. -- Circular references are handled. Metatables are included in the comparison. -- @static diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index 0bd73f5..68597eb 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -1,5 +1,4 @@ json = require "json" -DownloadManager = require "DM.DownloadManager" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" @@ -45,7 +44,6 @@ class UpdateFeed extends Common } @defaultConfig = { - downloadPath: aegisub.decode_path "?temp/l0.#{@@__name}_feedCache" dumpExpanded: false } @cache = {} @@ -75,12 +73,7 @@ class UpdateFeed extends Common new: (@url, autoFetch = true, fileName, @config = {}, @logger = defaultLogger) => -- fill in missing config values @config[k] = v for k, v in pairs @@defaultConfig when @config[k] == nil - - -- delete old feeds - 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 + @fileName = fileName if @@cache[@url] @logger\trace msgs.trace.usingCached @data = @@cache[@url] @@ -99,6 +92,12 @@ class UpdateFeed extends Common -- @return table|boolean dataOrSuccess -- @return string|nil err fetch: (fileName) => + -- Initialize download infrastructure lazily on first fetch. + unless @downloadManager + @config.downloadPath or= aegisub.decode_path "?temp/l0.#{@@__name}_feedCache" + feedsHaveBeenTrimmed or= Logger(fileMatchTemplate: fileMatchTemplate, logDir: @config.downloadPath, maxFiles: 20)\trimFiles! + @fileName or= table.concat {@config.downloadPath, fileBaseName, "%04X"\format(math.random 0, 16^4-1), ".json"} + @downloadManager = (require "DM.DownloadManager") aegisub.decode_path @config.downloadPath @fileName = fileName if fileName dl, err = @downloadManager\addDownload @url, @fileName @@ -107,15 +106,23 @@ class UpdateFeed extends Common @downloadManager\waitForFinish -> true if dl.error - return false, msgs.errors.downloadFailed\format @url, @fileName, dl.error + return false, msgs.errors.downloadFailed\format @url, @fileName, dl.error @logger\trace msgs.trace.downloaded, @fileName - - handle, err = io.open @fileName + return @loadFile @fileName + + --- Loads and parses a local feed JSON file, expanding all template variables in-place. + -- Use this to load a feed already on disk without going through the network. + ---@param path string Local filesystem path to the feed JSON file. + ---@return table|boolean + ---@return string|nil err + loadFile: (path) => + handle, err = io.open path unless handle return false, msgs.errors.cantOpen\format err decoded, data = pcall json.decode, handle\read "*a" + handle\close! unless decoded and data -- luajson errors are useless dumps of whatever, no use to pass them on to the user return false, msgs.errors.parse diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index 5d6329a..f07ac81 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -334,11 +334,9 @@ class UpdateTask extends UpdaterBase baseName = scriptSubDir .. file.name tmpName, prettyName = "#{tmpDir}/#{file.type}/#{baseName}", baseName switch file.type - when "script" - file.fullName = "#{@record.automationDir}/#{baseName}" - when "test" - file.fullName = "#{@record.testDir}/#{baseName}" - prettyName ..= " (Unit Test)" + when "script", "test" + file.fullName = Common\getFileDeployPath @record.namespace, @record.scriptType, file.name, file.type + prettyName ..= " (Unit Test)" if file.type == "test" else file.unknown = true @logger\log msgs.performUpdate.unknownType, file.name, file.type diff --git a/modules/DependencyControl/ZipArchiver.moon b/modules/DependencyControl/ZipArchiver.moon new file mode 100644 index 0000000..b635845 --- /dev/null +++ b/modules/DependencyControl/ZipArchiver.moon @@ -0,0 +1,165 @@ +lfs = require "lfs" +ffi = require "ffi" +Logger = require "l0.DependencyControl.Logger" +FileOps = require "l0.DependencyControl.FileOps" +json = require "l0.dkjson" + +defaultLogger = Logger fileBaseName: "DepCtrl.ZipArchiver" + +-- Windows helper: drives .NET's System.IO.Compression ZipArchive directly, reading +-- the (source → entry name) mapping from a JSON manifest. Entry names are taken +-- verbatim from the manifest (always forward-slash), which sidesteps both the +-- Compress-Archive cmdlet's backslash bug and the legacy ZipFile.CreateFromDirectory +-- quirk — so it works on stock Windows PowerShell out-of-the-box. +windowsBuilderScript = [[ +$ErrorActionPreference = 'Stop' +$manifest = $args[0] +$dest = $args[1] +Add-Type -AssemblyName System.IO.Compression.FileSystem +$entries = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json +$zip = [System.IO.Compression.ZipFile]::Open($dest, 'Create') +try { + foreach ($e in $entries) { + $entry = $zip.CreateEntry($e.name, [System.IO.Compression.CompressionLevel]::Optimal) + $out = $entry.Open() + $in = [System.IO.File]::OpenRead($e.source) + $in.CopyTo($out) + $in.Dispose() + $out.Dispose() + } +} finally { + $zip.Dispose() +} +]] + +-- Runs a shell command and reports success. +-- os.execute returns the exit code (Lua 5.1) or a boolean (5.2+/LUA52COMPAT). +execOk = (cmd) -> + r = os.execute cmd + return (type(r) == "number" and r == 0) or r == true + +--- Builds zip archives using each platform's stock tooling — no extra rocks or +-- shared libraries to install or locate. Files are added with explicit, forward-slash +-- entry names so the resulting archives extract correctly on every platform. +-- Compression only for now; reading/extraction can be added when needed. +-- +-- archiver = ZipArchiver outputPath +-- archiver\addDirectory distDir +-- archiver\addFile readmePath, "README.md" +-- success, err = archiver\write! +-- +-- @class ZipArchiver +class ZipArchiver + isWindows = ffi.os == "Windows" + pathSep = FileOps.pathSep + + msgs = { + errors: { + noEntries: "No files have been added to the archive." + helperWrite: "Couldn't write the archive helper file (%s)." + stageFailed: "Couldn't stage '%s' for archiving (%s)." + enterStage: "Couldn't enter the staging directory '%s'." + zipFailed: "Archive creation failed (the '%s' tool reported an error)." + } + } + + --- Creates an archiver that will write a zip to `outputPath`. + ---@param outputPath string Absolute path of the archive to create. + ---@param logger? Logger + new: (@outputPath, @logger = defaultLogger) => + @entries = {} + + --- Adds a single file under `archiveName` (a forward-slash path within the archive). + ---@param sourcePath string Absolute path of the file to add. + ---@param archiveName string Name/path the file should have inside the archive. + ---@return ZipArchiver self for chaining. + addFile: (sourcePath, archiveName) => + @entries[#@entries + 1] = {source: sourcePath, name: archiveName} + return @ + + --- Adds every file beneath `sourceDir`, naming each entry by its path relative to + -- `sourceDir` (optionally below `archivePrefix`). + ---@param sourceDir string Absolute path of the directory to add. + ---@param archivePrefix? string Optional path prefix for the entries inside the archive. + ---@return ZipArchiver self for chaining. + addDirectory: (sourceDir, archivePrefix = "") => + return @ unless lfs.attributes sourceDir, "mode" + prefix = archivePrefix == "" and "" or "#{archivePrefix\gsub '[\\/]+$', ''}/" + + recurse = (dir, rel) -> + for name in lfs.dir dir + continue if name == "." or name == ".." + full = "#{dir}#{pathSep}#{name}" + entryName = rel == "" and name or "#{rel}/#{name}" + if lfs.attributes(full, "mode") == "directory" + recurse full, entryName + else + @entries[#@entries + 1] = {source: full, name: prefix .. entryName} + + recurse sourceDir, "" + return @ + + --- Writes the archive to `outputPath`. Returns true on success, or nil plus an + -- error message. + ---@return boolean|nil success + ---@return string|nil err + write: => + return nil, msgs.errors.noEntries if #@entries == 0 + FileOps.remove @outputPath -- ZipArchive 'Create' mode requires the target to be absent + return @_writeWindows! if isWindows + return @_writeUnix! + + -- Windows: write a JSON manifest plus the helper script to the temp dir, then run + -- it via -File (only path arguments to quote, avoiding cmd.exe quoting pitfalls). + _writeWindows: => + token = "%04X"\format math.random 0, 16^4 - 1 + tmpDir = aegisub.decode_path "?temp" + manifestPath = "#{tmpDir}#{pathSep}depctrl-ziparchiver-#{token}.json" + scriptPath = "#{tmpDir}#{pathSep}depctrl-ziparchiver-#{token}.ps1" + + cleanup = -> + os.remove manifestPath + os.remove scriptPath + + mh, err = io.open manifestPath, "w" + return nil, msgs.errors.helperWrite\format err unless mh + mh\write(json.encode @entries)\close! + + sh, err = io.open scriptPath, "w" + unless sh + cleanup! + return nil, msgs.errors.helperWrite\format err + sh\write(windowsBuilderScript)\close! + + success = execOk ([[powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%s" "%s" "%s"]])\format scriptPath, manifestPath, @outputPath + cleanup! + return true if success + return nil, msgs.errors.zipFailed\format "PowerShell" + + -- Unix: the `zip` CLI can't rename entries, so stage each file into a temp tree at + -- its archive name, then archive that tree from the inside. + _writeUnix: => + token = "%04X"\format math.random 0, 16^4 - 1 + stageDir = "#{aegisub.decode_path '?temp'}#{pathSep}depctrl-ziparchiver-#{token}" + FileOps.mkdir stageDir, false, true + + for entry in *@entries + target = "#{stageDir}/#{entry.name}" + FileOps.mkdir target, true, true -- create the entry's parent directories + ok, err = FileOps.copy entry.source, target + unless ok + FileOps.remove stageDir, true + return nil, msgs.errors.stageFailed\format entry.source, err + + prevDir = lfs.currentdir! + unless lfs.chdir stageDir + FileOps.remove stageDir, true + return nil, msgs.errors.enterStage\format stageDir + + names = [("'%s'")\format name for name in lfs.dir stageDir when name != "." and name != ".."] + success = execOk ([[zip -r -q -X "%s" %s]])\format @outputPath, table.concat names, " " + lfs.chdir prevDir + FileOps.remove stageDir, true + + return true if success + return nil, msgs.errors.zipFailed\format "zip" diff --git a/run-tests.lua b/run-tests.lua deleted file mode 100644 index dccef1e..0000000 --- a/run-tests.lua +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env luajit --- Headless DependencyControl test runner. --- --- Runs the DepCtrl unit test suite from the command line (locally or in CI) without --- an Aegisub process. Requires LuaJIT (built with LUA52COMPAT=1) plus the LuaRocks --- modules `moonscript` and `luafilesystem`. JSON is vendored (dkjson), and the FFI --- timer/mutex/downloader are bundled, so no other external modules are needed. --- --- luajit run-tests.lua [ctrf-report-path] --- --- An optional first argument sets where the CTRF test report is written; it --- defaults to ctrf/DependencyControl.json next to this script. --- Exit code is 0 when every test passes, 1 otherwise. - -local ffi = require "ffi" -local lfs = require "lfs" -require "moonscript" -local moonbase = require "moonscript.base" - --- normally provided by the hosting macro in Aegisub environments -script_namespace = "DepCtrl.Tests" - -local isWindows = ffi.os == "Windows" -local pathSep = isWindows and "\\" or "/" - --- Utility functions - -local function dirname(path) - return (path or ""):match("^(.*)[/\\][^/\\]*$") or "." -end - -local function isAbsolute(path) - return path:match("^%a:[/\\]") ~= nil -- "C:\..." - or path:match("^[/\\]") ~= nil -- "/..." or "\..." -end - -local function fileExists(path) - local f = io.open(path, "r") - if f then - f:close() - return true - end - return false -end - - --- Resolve the repo root from this script's own location so the runner works regardless --- of the current working directory. The path is made absolute up front so module --- resolution can't be thrown off by anything that changes the process CWD mid-run. - -local testLauncherScriptDir = dirname(arg and arg[0]) -if testLauncherScriptDir == "." then - testLauncherScriptDir = lfs.currentdir() -- avoid a trailing "\." segment -elseif not isAbsolute(testLauncherScriptDir) then - testLauncherScriptDir = lfs.currentdir() .. pathSep .. testLauncherScriptDir -end -local modulesDir = testLauncherScriptDir .. pathSep .. "modules" - --- Custom searcher mapping the `l0.` namespace onto the repo's `modules/` directory, --- loading either MoonScript sources or plain Lua files. Appended last so it only fires --- for our own modules; everything else (lfs, ffi, moonscript) resolves through the stock --- searchers. The bare aliases "json", "BM.BadMutex" and "DM.DownloadManager" are routed --- to their `l0.` providers by DepCtrl's own ModuleProvider searcher, which in turn lands --- back here. -local function l0ModuleSearcher(name) - local moduleNameWithoutNamespace = name:match("^l0%.(.+)$") - if not moduleNameWithoutNamespace then return nil end - - local moduleRelativePath = moduleNameWithoutNamespace:gsub("%.", pathSep) - local basePath = modulesDir .. pathSep .. moduleRelativePath - local candidates = { - { path = basePath .. ".moon", moon = true }, - { path = basePath .. ".lua", moon = false }, - { path = basePath .. pathSep .. "init.moon", moon = true }, - { path = basePath .. pathSep .. "init.lua", moon = false }, - } - for _, c in ipairs(candidates) do - if fileExists(c.path) then - local chunk, err = (c.moon and moonbase.loadfile or loadfile)(c.path) - if not chunk then error(err) end - return chunk - end - end - return "\n\tno l0 module file for '" .. name .. "' under " .. modulesDir -end -table.insert(package.loaders or package.searchers, l0ModuleSearcher) - --- Install the Aegisub global shim. It exposes a small configuration API which we use to --- point the path tokens at a throwaway workspace, so the suite's log files and feed caches --- land somewhere writable instead of the user's real Aegisub config directory. -local shims = require "l0.AegisubShims" - -local workspace = shims.getPathToken("temp") .. pathSep .. ("depctrl-tests-%x"):format(os.time() % 0x100000) -for _, token in ipairs({ "user", "local", "data", "temp" }) do - shims.setPathToken(token, workspace .. pathSep .. token) -end - --- Make sure the directories the shim now resolves actually exist. FileOps.mkdir with the --- recurse flag creates any missing parents (validateFullPath expands the path tokens). -local FileOps = require "l0.DependencyControl.FileOps" -FileOps.mkdir("?temp", false, true) -FileOps.mkdir("?user/log", false, true) - --- Load DepCtrl (triggers the ModuleProvider bootstrap for json/BadMutex/DownloadManager) --- and its test suite, then run it. -local DepCtrl = require "l0.DependencyControl" -local suite = require "l0.DependencyControl.Tests" - -suite:import(DepCtrl) -local success = suite:run() - --- Write CTRF test report -local reportPath = arg[1] or (testLauncherScriptDir .. pathSep .. "ctrf" .. pathSep .. "DependencyControl.json") -local wrote, writeErr = suite:writeResults(reportPath) - -io.stderr:write(wrote and ("\nWrote CTRF report to " .. reportPath .. "\n") - or ("\nWarning: couldn't write CTRF report: " .. tostring(writeErr) .. "\n")) - -io.stderr:write(success and "\nAll DependencyControl tests passed.\n" - or "\nDependencyControl tests FAILED.\n") -os.exit(success and 0 or 1) From 91b089a40c6f88b0093e5b545e82a128aa02a66a Mon Sep 17 00:00:00 2001 From: line0 Date: Thu, 4 Jun 2026 20:09:35 +0200 Subject: [PATCH 39/47] refactor: add namespace to module paths in repo allows us to remove the custom module searcher in the depctrl CLI --- DependencyControl.json | 4 +- README.md | 2 +- depctrl.lua | 37 ++++--------------- modules/{ => l0}/AegisubShims.moon | 0 modules/{ => l0}/AegisubShims/aegisub.moon | 0 modules/{ => l0}/DependencyControl.moon | 0 .../{ => l0}/DependencyControl/Common.moon | 0 .../DependencyControl/ConfigHandler.moon | 0 .../DependencyControl/ConfigView.moon | 0 .../{ => l0}/DependencyControl/Crypto.moon | 0 .../DependencyControl/DownloadManager.moon | 0 .../DependencyControl/Downloader.moon | 0 modules/{ => l0}/DependencyControl/Enum.moon | 0 .../DependencyControl/EventEmitter.moon | 0 .../{ => l0}/DependencyControl/FileOps.moon | 0 modules/{ => l0}/DependencyControl/Lock.moon | 0 .../{ => l0}/DependencyControl/Logger.moon | 0 .../DependencyControl/ModuleLoader.moon | 0 .../DependencyControl/ModuleProvider.moon | 0 .../{ => l0}/DependencyControl/Record.moon | 0 .../DependencyControl/ScriptUpdateRecord.moon | 0 .../DependencyControl/SemanticVersioning.moon | 0 modules/{ => l0}/DependencyControl/Stub.moon | 0 .../DependencyControl/TerribleMutex.moon | 0 modules/{ => l0}/DependencyControl/Tests.moon | 0 modules/{ => l0}/DependencyControl/Timer.moon | 0 .../DependencyControl/UnitTestSuite.moon | 0 .../DependencyControl/UpdateFeed.moon | 0 .../{ => l0}/DependencyControl/Updater.moon | 0 .../DependencyControl/ZipArchiver.moon | 0 .../helpers/MockHttpServerController.moon | 0 .../test/helpers/mock-http-server.moon | 0 modules/{ => l0}/dkjson.moon | 2 +- modules/{ => l0}/dkjson/vendor/dkjson.lua | 0 34 files changed, 11 insertions(+), 34 deletions(-) rename modules/{ => l0}/AegisubShims.moon (100%) rename modules/{ => l0}/AegisubShims/aegisub.moon (100%) rename modules/{ => l0}/DependencyControl.moon (100%) rename modules/{ => l0}/DependencyControl/Common.moon (100%) rename modules/{ => l0}/DependencyControl/ConfigHandler.moon (100%) rename modules/{ => l0}/DependencyControl/ConfigView.moon (100%) rename modules/{ => l0}/DependencyControl/Crypto.moon (100%) rename modules/{ => l0}/DependencyControl/DownloadManager.moon (100%) rename modules/{ => l0}/DependencyControl/Downloader.moon (100%) rename modules/{ => l0}/DependencyControl/Enum.moon (100%) rename modules/{ => l0}/DependencyControl/EventEmitter.moon (100%) rename modules/{ => l0}/DependencyControl/FileOps.moon (100%) rename modules/{ => l0}/DependencyControl/Lock.moon (100%) rename modules/{ => l0}/DependencyControl/Logger.moon (100%) rename modules/{ => l0}/DependencyControl/ModuleLoader.moon (100%) rename modules/{ => l0}/DependencyControl/ModuleProvider.moon (100%) rename modules/{ => l0}/DependencyControl/Record.moon (100%) rename modules/{ => l0}/DependencyControl/ScriptUpdateRecord.moon (100%) rename modules/{ => l0}/DependencyControl/SemanticVersioning.moon (100%) rename modules/{ => l0}/DependencyControl/Stub.moon (100%) rename modules/{ => l0}/DependencyControl/TerribleMutex.moon (100%) rename modules/{ => l0}/DependencyControl/Tests.moon (100%) rename modules/{ => l0}/DependencyControl/Timer.moon (100%) rename modules/{ => l0}/DependencyControl/UnitTestSuite.moon (100%) rename modules/{ => l0}/DependencyControl/UpdateFeed.moon (100%) rename modules/{ => l0}/DependencyControl/Updater.moon (100%) rename modules/{ => l0}/DependencyControl/ZipArchiver.moon (100%) rename modules/{ => l0}/DependencyControl/test/helpers/MockHttpServerController.moon (100%) rename modules/{ => l0}/DependencyControl/test/helpers/mock-http-server.moon (100%) rename modules/{ => l0}/dkjson.moon (97%) rename modules/{ => l0}/dkjson/vendor/dkjson.lua (100%) diff --git a/DependencyControl.json b/DependencyControl.json index 9c75da7..080914b 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -74,7 +74,7 @@ "author": "line0", "name": "DependencyControl", "description": "Dependency manager and automatic script updater for Aegisub macros and modules.", - "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{scriptName}", + "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}", "channels": { "alpha": { "version": "0.7.0", @@ -228,7 +228,7 @@ "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}", + "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}", "channels": { "release": { "version": "2.10.0", diff --git a/README.md b/README.md index 4547c2b..b087dc2 100644 --- a/README.md +++ b/README.md @@ -604,7 +604,7 @@ tbd ### UnitTestSuite -Reference documentation for the UnitTestSuite module is available in the [source code](https://github.com/TypesettingTools/DependencyControl/blob/master/modules/DependencyControl/UnitTestSuite.moon#L760) +Reference documentation for the UnitTestSuite module is available in the [source code](https://github.com/TypesettingTools/DependencyControl/blob/master/modules/l0/DependencyControl/UnitTestSuite.moon#L760) ### UpdateFeed diff --git a/depctrl.lua b/depctrl.lua index 0263d9f..2769a18 100644 --- a/depctrl.lua +++ b/depctrl.lua @@ -15,8 +15,7 @@ local ffi = require "ffi" local lfs = require "lfs" -require "moonscript" -local moonbase = require "moonscript.base" +require "moonscript" -- installs moonscript's package.moonpath loader for .moon files -- ── Path utilities ──────────────────────────────────────────────────────────── @@ -47,35 +46,13 @@ if launcherDir == "." then elseif not isAbsolute(launcherDir) then launcherDir = lfs.currentdir() .. pathSep .. launcherDir end +-- ── Module resolution ────────────────────────────────────────────────────────── +-- The repo's modules/ tree is namespaced (modules/l0/…), so l0.* require paths map +-- straight onto it: moonscript's loader resolves .moon via package.moonpath, the +-- stock searcher the vendored .lua via package.path. No custom searcher needed. local modulesDir = launcherDir .. pathSep .. "modules" - --- ── Module searcher ─────────────────────────────────────────────────────────── --- Maps l0.* names onto the repo's modules/ tree, loading .moon or .lua sources. --- Appended last so stock searchers (lfs, ffi, moonscript) still take precedence; --- bare aliases like "json" are resolved by DepCtrl's ModuleProvider once loaded. - -local function l0ModuleSearcher(name) - local sub = name:match("^l0%.(.+)$") - if not sub then return nil end - - local relPath = sub:gsub("%.", pathSep) - local base = modulesDir .. pathSep .. relPath - local candidates = { - { path = base .. ".moon", moon = true }, - { path = base .. ".lua", moon = false }, - { path = base .. pathSep .. "init.moon", moon = true }, - { path = base .. pathSep .. "init.lua", moon = false }, - } - for _, c in ipairs(candidates) do - if fileExists(c.path) then - local chunk, err = (c.moon and moonbase.loadfile or loadfile)(c.path) - if not chunk then error(err) end - return chunk - end - end - return "\n\tno l0 module file for '" .. name .. "' under " .. modulesDir -end -table.insert(package.loaders or package.searchers, l0ModuleSearcher) +package.path = ("%s/?.lua;%s/?/init.lua;"):format(modulesDir, modulesDir) .. package.path +package.moonpath = ("%s/?.moon;%s/?/init.moon;"):format(modulesDir, modulesDir) .. (package.moonpath or "") -- ── Aegisub shims ───────────────────────────────────────────────────────────── diff --git a/modules/AegisubShims.moon b/modules/l0/AegisubShims.moon similarity index 100% rename from modules/AegisubShims.moon rename to modules/l0/AegisubShims.moon diff --git a/modules/AegisubShims/aegisub.moon b/modules/l0/AegisubShims/aegisub.moon similarity index 100% rename from modules/AegisubShims/aegisub.moon rename to modules/l0/AegisubShims/aegisub.moon diff --git a/modules/DependencyControl.moon b/modules/l0/DependencyControl.moon similarity index 100% rename from modules/DependencyControl.moon rename to modules/l0/DependencyControl.moon diff --git a/modules/DependencyControl/Common.moon b/modules/l0/DependencyControl/Common.moon similarity index 100% rename from modules/DependencyControl/Common.moon rename to modules/l0/DependencyControl/Common.moon diff --git a/modules/DependencyControl/ConfigHandler.moon b/modules/l0/DependencyControl/ConfigHandler.moon similarity index 100% rename from modules/DependencyControl/ConfigHandler.moon rename to modules/l0/DependencyControl/ConfigHandler.moon diff --git a/modules/DependencyControl/ConfigView.moon b/modules/l0/DependencyControl/ConfigView.moon similarity index 100% rename from modules/DependencyControl/ConfigView.moon rename to modules/l0/DependencyControl/ConfigView.moon diff --git a/modules/DependencyControl/Crypto.moon b/modules/l0/DependencyControl/Crypto.moon similarity index 100% rename from modules/DependencyControl/Crypto.moon rename to modules/l0/DependencyControl/Crypto.moon diff --git a/modules/DependencyControl/DownloadManager.moon b/modules/l0/DependencyControl/DownloadManager.moon similarity index 100% rename from modules/DependencyControl/DownloadManager.moon rename to modules/l0/DependencyControl/DownloadManager.moon diff --git a/modules/DependencyControl/Downloader.moon b/modules/l0/DependencyControl/Downloader.moon similarity index 100% rename from modules/DependencyControl/Downloader.moon rename to modules/l0/DependencyControl/Downloader.moon diff --git a/modules/DependencyControl/Enum.moon b/modules/l0/DependencyControl/Enum.moon similarity index 100% rename from modules/DependencyControl/Enum.moon rename to modules/l0/DependencyControl/Enum.moon diff --git a/modules/DependencyControl/EventEmitter.moon b/modules/l0/DependencyControl/EventEmitter.moon similarity index 100% rename from modules/DependencyControl/EventEmitter.moon rename to modules/l0/DependencyControl/EventEmitter.moon diff --git a/modules/DependencyControl/FileOps.moon b/modules/l0/DependencyControl/FileOps.moon similarity index 100% rename from modules/DependencyControl/FileOps.moon rename to modules/l0/DependencyControl/FileOps.moon diff --git a/modules/DependencyControl/Lock.moon b/modules/l0/DependencyControl/Lock.moon similarity index 100% rename from modules/DependencyControl/Lock.moon rename to modules/l0/DependencyControl/Lock.moon diff --git a/modules/DependencyControl/Logger.moon b/modules/l0/DependencyControl/Logger.moon similarity index 100% rename from modules/DependencyControl/Logger.moon rename to modules/l0/DependencyControl/Logger.moon diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/l0/DependencyControl/ModuleLoader.moon similarity index 100% rename from modules/DependencyControl/ModuleLoader.moon rename to modules/l0/DependencyControl/ModuleLoader.moon diff --git a/modules/DependencyControl/ModuleProvider.moon b/modules/l0/DependencyControl/ModuleProvider.moon similarity index 100% rename from modules/DependencyControl/ModuleProvider.moon rename to modules/l0/DependencyControl/ModuleProvider.moon diff --git a/modules/DependencyControl/Record.moon b/modules/l0/DependencyControl/Record.moon similarity index 100% rename from modules/DependencyControl/Record.moon rename to modules/l0/DependencyControl/Record.moon diff --git a/modules/DependencyControl/ScriptUpdateRecord.moon b/modules/l0/DependencyControl/ScriptUpdateRecord.moon similarity index 100% rename from modules/DependencyControl/ScriptUpdateRecord.moon rename to modules/l0/DependencyControl/ScriptUpdateRecord.moon diff --git a/modules/DependencyControl/SemanticVersioning.moon b/modules/l0/DependencyControl/SemanticVersioning.moon similarity index 100% rename from modules/DependencyControl/SemanticVersioning.moon rename to modules/l0/DependencyControl/SemanticVersioning.moon diff --git a/modules/DependencyControl/Stub.moon b/modules/l0/DependencyControl/Stub.moon similarity index 100% rename from modules/DependencyControl/Stub.moon rename to modules/l0/DependencyControl/Stub.moon diff --git a/modules/DependencyControl/TerribleMutex.moon b/modules/l0/DependencyControl/TerribleMutex.moon similarity index 100% rename from modules/DependencyControl/TerribleMutex.moon rename to modules/l0/DependencyControl/TerribleMutex.moon diff --git a/modules/DependencyControl/Tests.moon b/modules/l0/DependencyControl/Tests.moon similarity index 100% rename from modules/DependencyControl/Tests.moon rename to modules/l0/DependencyControl/Tests.moon diff --git a/modules/DependencyControl/Timer.moon b/modules/l0/DependencyControl/Timer.moon similarity index 100% rename from modules/DependencyControl/Timer.moon rename to modules/l0/DependencyControl/Timer.moon diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/l0/DependencyControl/UnitTestSuite.moon similarity index 100% rename from modules/DependencyControl/UnitTestSuite.moon rename to modules/l0/DependencyControl/UnitTestSuite.moon diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/l0/DependencyControl/UpdateFeed.moon similarity index 100% rename from modules/DependencyControl/UpdateFeed.moon rename to modules/l0/DependencyControl/UpdateFeed.moon diff --git a/modules/DependencyControl/Updater.moon b/modules/l0/DependencyControl/Updater.moon similarity index 100% rename from modules/DependencyControl/Updater.moon rename to modules/l0/DependencyControl/Updater.moon diff --git a/modules/DependencyControl/ZipArchiver.moon b/modules/l0/DependencyControl/ZipArchiver.moon similarity index 100% rename from modules/DependencyControl/ZipArchiver.moon rename to modules/l0/DependencyControl/ZipArchiver.moon diff --git a/modules/DependencyControl/test/helpers/MockHttpServerController.moon b/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon similarity index 100% rename from modules/DependencyControl/test/helpers/MockHttpServerController.moon rename to modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon diff --git a/modules/DependencyControl/test/helpers/mock-http-server.moon b/modules/l0/DependencyControl/test/helpers/mock-http-server.moon similarity index 100% rename from modules/DependencyControl/test/helpers/mock-http-server.moon rename to modules/l0/DependencyControl/test/helpers/mock-http-server.moon diff --git a/modules/dkjson.moon b/modules/l0/dkjson.moon similarity index 97% rename from modules/dkjson.moon rename to modules/l0/dkjson.moon index b6df2c9..cf276aa 100644 --- a/modules/dkjson.moon +++ b/modules/l0/dkjson.moon @@ -1,6 +1,6 @@ -- DependencyControl wrapper around the vendored upstream dkjson. -- --- The upstream library is kept pristine and unmodified at `modules/dkjson/vendor/dkjson.lua` +-- The upstream library is kept pristine and unmodified at `modules/l0/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. -- diff --git a/modules/dkjson/vendor/dkjson.lua b/modules/l0/dkjson/vendor/dkjson.lua similarity index 100% rename from modules/dkjson/vendor/dkjson.lua rename to modules/l0/dkjson/vendor/dkjson.lua From 6e4c4e705d01b696b60a65af19454b78f09d7fc1 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 6 Jun 2026 20:47:21 +0200 Subject: [PATCH 40/47] feat: CLI deploy command; CLI commands work for external repos; CLI test command can run on multiple packages; multi-file test suites --- .github/workflows/test.yml | 2 + DependencyControl.json | 7 +- README.md | 470 ++++++++++-------- depctrl.lua | 410 +++++++-------- modules/l0/DependencyControl.moon | 35 +- modules/l0/DependencyControl/Common.moon | 75 ++- modules/l0/DependencyControl/FileOps.moon | 157 ++++-- .../l0/DependencyControl/GitRepository.moon | 28 ++ modules/l0/DependencyControl/Record.moon | 42 +- .../DependencyControl/ScriptTargetFilter.moon | 71 +++ .../l0/DependencyControl/UnitTestSuite.moon | 70 ++- modules/l0/DependencyControl/UpdateFeed.moon | 188 ++++++- modules/l0/DependencyControl/Updater.moon | 5 +- .../{Tests.moon => test.moon} | 35 +- modules/l0/DependencyControl/test/Common.moon | 99 ++++ .../l0/DependencyControl/test/FileOps.moon | 99 ++++ .../DependencyControl/test/GitRepository.moon | 84 ++++ .../test/ScriptTargetFilter.moon | 71 +++ .../l0/DependencyControl/test/UpdateFeed.moon | 165 ++++++ schemas/feed/{v0.3.0.json => v0.4.0.json} | 26 +- 20 files changed, 1624 insertions(+), 515 deletions(-) create mode 100644 modules/l0/DependencyControl/GitRepository.moon create mode 100644 modules/l0/DependencyControl/ScriptTargetFilter.moon rename modules/l0/DependencyControl/{Tests.moon => test.moon} (98%) create mode 100644 modules/l0/DependencyControl/test/Common.moon create mode 100644 modules/l0/DependencyControl/test/FileOps.moon create mode 100644 modules/l0/DependencyControl/test/GitRepository.moon create mode 100644 modules/l0/DependencyControl/test/ScriptTargetFilter.moon create mode 100644 modules/l0/DependencyControl/test/UpdateFeed.moon rename schemas/feed/{v0.3.0.json => v0.4.0.json} (86%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08ec8ed..b6d4a62 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,8 @@ jobs: - run: luarocks install moonscript # lfs (Aegisub provides an internal copy) - run: luarocks install luafilesystem + # CLI argument parsing + - run: luarocks install argparse # mock HTTP server dependencies - run: luarocks install luasocket - run: luarocks install copas diff --git a/DependencyControl.json b/DependencyControl.json index 080914b..e623cef 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -1,5 +1,5 @@ { - "dependencyControlFeedFormatVersion": "0.3.0", + "dependencyControlFeedFormatVersion": "0.4.0", "name": "DependencyControl", "description": "The official DependencyControl repository.", "baseUrl": "https://github.com/TypesettingTools/DependencyControl", @@ -30,6 +30,7 @@ "name": "DependencyControl Toolbox", "description": "Provides DependencyControl maintenance and configuration utilities.", "fileBaseUrl": "@{fileBaseUrl}macros-v@{version}-@{channel}/macros/@{namespace}", + "localFileBasePath": "@{localFileBasePath}macros/@{namespace}", "channels": { "alpha": { "version": "0.2.0", @@ -75,6 +76,7 @@ "name": "DependencyControl", "description": "Dependency manager and automatic script updater for Aegisub macros and modules.", "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}", + "localFileBasePath": "@{localFileBasePath}modules/@{namespacePath}", "channels": { "alpha": { "version": "0.7.0", @@ -139,7 +141,7 @@ { "name": ".moon", "type": "test", - "url": "@{fileBaseUrl}/Tests.moon", + "url": "@{fileBaseUrl}/test.moon", "sha1": "1ED8961CAFCADA7E4C04778227EECDC18E509B8D" } ], @@ -229,6 +231,7 @@ "name": "dkjson", "description": "David Kolf's JSON module for Lua, vendored with and managed by DependencyControl.", "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}", + "localFileBasePath": "@{localFileBasePath}modules/@{namespacePath}", "channels": { "release": { "version": "2.10.0", diff --git a/README.md b/README.md index b087dc2..c91ace9 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,43 @@ DependencyControl provides versioning, automatic script update, dependency management and script management services to Aegisub macros and modules. -__Features__: +**Features**: - * A lightweight package manager lets users conveniently install scripts right from inside Aegisub - * Loads modules used by an automation script, pulls missing requirements from the internet and informs the user about missing and outdated modules that could not be updated automatically - * Checks scripts and modules for updates and automatically installs them - * Offers convenient macro registration with user-customizable submenus - * Provides configuration, logging services, file operations and a unit test framework for your scripts - * Supports optional modules and private module copies for cases where an older or custom version of a module is required - * Resolves circular dependencies (limitations apply) +- A lightweight package manager lets users conveniently install scripts right from inside Aegisub +- Loads modules used by an automation script, pulls missing requirements from the internet and informs the user about missing and outdated modules that could not be updated automatically +- Checks scripts and modules for updates and automatically installs them +- Offers convenient macro registration with user-customizable submenus +- Provides configuration, logging services, file operations and a unit test framework for your scripts +- Supports optional modules and private module copies for cases where an older or custom version of a module is required +- Resolves circular dependencies (limitations apply) -__Requirements__: +**Requirements**: - * Aegisub [v3.4.0+](https://github.com/TypesettingTools/Aegisub/releases) or releases of [arch1t3cht's Aegisub fork](https://github.com/arch1t3cht/Aegisub/releases) based on v3.4.0+. Older versions of Aegisub may work, but you're on your own if you run into any issues. +- Aegisub [v3.4.0+](https://github.com/TypesettingTools/Aegisub/releases) or releases of [arch1t3cht's Aegisub fork](https://github.com/arch1t3cht/Aegisub/releases) based on v3.4.0+. Older versions of Aegisub may work, but you're on your own if you run into any issues. DependencyControl is self-contained: it bundles a JSON library ([dkjson](https://dkolf.de/dkjson-lua/)), though if you have another `json` module installed, it is used instead. It also now ships with pure-FFI -implementations of functionality previously provided by +implementations of functionality previously provided by [ffi-experiments](https://github.com/torque/ffi-experiments) modules (_DownloadManager_, _BadMutex_, _PreciseTimer_). ----------------------------------- +--- ## Table of Contents - 1. [DependencyControl for Users](#dependency-control-for-users) - 2. [Usage for Automation Scripts](#usage-for-automation-scripts) - 3. [Namespaces and Paths](#namespaces-and-paths) - 4. [The Anatomy of an Updater Feed](#the-anatomy-of-an-updater-feed) - 5. [Reference](#reference) - 1. [DependencyControl](#FIXME) - 2. [Updater](#FIXME) - 3. [Logger](#FIXME) - 4. [ConfigHandler](#FIXME) - 5. [FileOps](#FIXME) +1. [DependencyControl for Users](#dependency-control-for-users) +2. [Usage for Automation Scripts](#usage-for-automation-scripts) +3. [Namespaces and Paths](#namespaces-and-paths) +4. [The Anatomy of an Updater Feed](#the-anatomy-of-an-updater-feed) +5. [Reference](#reference) +6. [DependencyControl](#FIXME) +7. [Updater](#FIXME) +8. [Logger](#FIXME) +9. [ConfigHandler](#FIXME) +10. [FileOps](#FIXME) +11. [CLI](#cli) ----------------------------------- +--- ## Dependency Control for Users @@ -45,13 +46,12 @@ As an end-user you don't get to decide whether your scripts use DependencyContro ### Installation - 1. Download the latest DependencyControl release unpack its contents to your Aegisub **user** automation directory: - +1. Download the latest DependencyControl release unpack its contents to your Aegisub **user** automation directory: - On Windows: `%AppData%\Aegisub\automation` - On Linux: `~/.aegisub/automation` - On OSX: `~/Library/Application Support/Aegisub/automation` - Do **NOT** unpack the file into the automation directory within the Aegisub installation folder, as this will break the updater. +Do **NOT** unpack the file into the automation directory within the Aegisub installation folder, as this will break the updater. 2. Restart Aegisub or re-scan your autoload directory from within the Aegisub _Automation Manger_. @@ -60,6 +60,7 @@ As an end-user you don't get to decide whether your scripts use DependencyContro DependencyControl comes with sane default settings, so if you're happy with that, there's no need to read further. If you want to disable the updater, use custom menus or want to tweak another aspect of DependencyControl, read on. DependencyControl stores its configuration as a JSON file in the `config` folder of your Aegisub user directory: + - On Windows: `%AppData%\Aegisub\config\l0.DependencyControl.json` - On Linux: `~/.aegisub/config/l0.DependencyControl.json` - On OSX: `~/Library/Application Support/Aegisub/config/l0.DependencyControl.json` @@ -69,34 +70,35 @@ The **DependencyControl Toolbox** macro provides a GUI for common management tas There are 2 kinds of configuration: #### 1. Global Configuration + Changes made in the `config` section of the configuration file will affect all scripts and general DependencyControl behavior. -__Available Fields__: +**Available Fields**: -* *bool* __updaterEnabled [true]:__ Turns the updater on/off -* *int* __updateInterval [3 Days]:__ The time in seconds between two update checks of a script -* *int* __traceLevel [3]:__ Sets the Trace level of DependencyControl update messages. Setting this higher than your _Trace level_ setting in Aegisub will prevent any of the messages from littering your log window. -* *bool* __dumpFeeds [true]:__ Debug option that will make DependencyControl dump updater feeds (original and expanded) to your Aegisub folder. -* *arr* __extraFeeds:__ lets you provide additional update feeds that will be used when checking any script for updates -* *bool* __tryAllFeeds [false]:__ When set to true (exhaustive mode), all candidate feeds are checked and the highest available version wins. When set to false (normal mode), the updater stops at the first feed that offers a newer version. -* *str* __configDir ["?user/config"]:__ Sets the configuration directory that will be "offered" to automation scripts (they may or may not actually use it) -* *str* __writeLogs [true]:__ When enabled, DependencyControl log messages will be written to a file in the Aegisub log folder. This is a valuable resource for debugging, especially since the Aegisub log window is not available during script initialization. -* *int* __logMaxFiles [200]:__ DependencyControl will purge old updater log files when any of the limits for log file count, log age and cumulative file size is exceeded. -* *int* __logMaxAge [1 Week]:__ Logs with a last modified date that exceeds this limit will be deleted. Takes a duration in seconds. -* *int* __logMaxSize [10 MB]:__ Cumulative file size limit for all log files in bytes. +- _bool_ **updaterEnabled [true]:** Turns the updater on/off +- _int_ **updateInterval [3 Days]:** The time in seconds between two update checks of a script +- _int_ **traceLevel [3]:** Sets the Trace level of DependencyControl update messages. Setting this higher than your _Trace level_ setting in Aegisub will prevent any of the messages from littering your log window. +- _bool_ **dumpFeeds [true]:** Debug option that will make DependencyControl dump updater feeds (original and expanded) to your Aegisub folder. +- _arr_ **extraFeeds:** lets you provide additional update feeds that will be used when checking any script for updates +- _bool_ **tryAllFeeds [false]:** When set to true (exhaustive mode), all candidate feeds are checked and the highest available version wins. When set to false (normal mode), the updater stops at the first feed that offers a newer version. +- _str_ **configDir ["?user/config"]:** Sets the configuration directory that will be "offered" to automation scripts (they may or may not actually use it) +- _str_ **writeLogs [true]:** When enabled, DependencyControl log messages will be written to a file in the Aegisub log folder. This is a valuable resource for debugging, especially since the Aegisub log window is not available during script initialization. +- _int_ **logMaxFiles [200]:** DependencyControl will purge old updater log files when any of the limits for log file count, log age and cumulative file size is exceeded. +- _int_ **logMaxAge [1 Week]:** Logs with a last modified date that exceeds this limit will be deleted. Takes a duration in seconds. +- _int_ **logMaxSize [10 MB]:** Cumulative file size limit for all log files in bytes. #### 2. Per-script Configuration -Changes made in the `macros` and `modules` sections of the configuration file affect only the script or module in question. -__Available Fields__: +Changes made in the `macros` and `modules` sections of the configuration file affect only the script or module in question. -* *str* __customMenu:__ If you want to sort your automation macros into submenus, set this to the submenu name (use `/` to denote submenu levels). -* *str* __userFeed:__ When set the updater will use this feed exclusively to update the script in question (instead of other feeds) -* *int* __lastUpdateCheck [auto]:__ This field is used to store the (epoch) time of the last update check. -* *int* __logLevel [3]:__ sets the default trace level for log messages from this script (only applies to messages sent through a Logger instance provided by DependencyControl to the script) -* *bool* __logToFile [false]:__ set the user preference wrt/ whether log messages of this script should be written to disk or not (same restrictions as above apply, may be overridden by the script) -* `author`, `configFile`, `feed`, `moduleName`, `name`, `namespace`, `url`, `requiredModules`, `version`, `unmanaged`, `provides`: These fields hold aspects of the script's version record. Don't change them (they will be reset anyway) +**Available Fields**: +- _str_ **customMenu:** If you want to sort your automation macros into submenus, set this to the submenu name (use `/` to denote submenu levels). +- _str_ **userFeed:** When set the updater will use this feed exclusively to update the script in question (instead of other feeds) +- _int_ **lastUpdateCheck [auto]:** This field is used to store the (epoch) time of the last update check. +- _int_ **logLevel [3]:** sets the default trace level for log messages from this script (only applies to messages sent through a Logger instance provided by DependencyControl to the script) +- _bool_ **logToFile [false]:** set the user preference wrt/ whether log messages of this script should be written to disk or not (same restrictions as above apply, may be overridden by the script) +- `author`, `configFile`, `feed`, `moduleName`, `name`, `namespace`, `url`, `requiredModules`, `version`, `unmanaged`, `provides`: These fields hold aspects of the script's version record. Don't change them (they will be reset anyway) ## Usage for Automation Scripts @@ -131,13 +133,13 @@ local version = DependencyControl{ local util, LineCollection, Line, Log, ASS, Common, YUtils = version:requireModules() ``` -Specifying a feed in your own version record provides DependencyControl with a source to download updates to your script from. +Specifying a feed in your own version record provides DependencyControl with a source to download updates to your script from. Specifying feeds for required modules managed by DependencyControl allows the Updater to discover those modules and fetch them when they're missing from the user's computer. However, you can omit the feed URLs for required modules when your own feed already has references to them. +To **register your macros** use the following code snippets instead of the usual _aegisub.register_macro()_ calls: -To **register your macros** use the following code snippets instead of the usual *aegisub.register_macro()* calls: +For a **single macro** that should be registered using the _script_name_ as automation menu entry, use: -For a **single macro** that should be registered using the *script_name* as automation menu entry, use: ```Lua version:registerMacro(myProcessingFunction) ``` @@ -155,7 +157,7 @@ Using this method for macro registration is a requirement for the **custom subme ### For Modules -Creating a record for a module is very similar to how it does for macros, with the key difference being that name and version information is passed to DependencyControl correctly and a *moduleName* is required. +Creating a record for a module is very similar to how it does for macros, with the key difference being that name and version information is passed to DependencyControl correctly and a _moduleName_ is required. ```lua local DependencyControl = require("l0.DependencyControl") @@ -182,8 +184,8 @@ local version = DependencyControl{ local createASSClass, re, util, unicode, Common, LineCollection, Line, Log, ASSInspector, YUtils = version:requireModules() ``` -A reference to the version record must be added as the *.version* field of your returned module for version control to work. -A module should also register itself to enable circular dependency support. The *:register()* method returns your module, so the last lines of your module should look like this: +A reference to the version record must be added as the _.version_ field of your returned module for version control to work. +A module should also register itself to enable circular dependency support. The _:register()_ method returns your module, so the last lines of your module should look like this: ```lua MyModule.version = version @@ -192,7 +194,7 @@ 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. +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{ @@ -206,37 +208,39 @@ local version = DependencyControl{ 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 +- 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 +- 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 -DependencyControl strictly enforces a **namespace-based file structure** for modules as well as automation macros in order to ensure there are no conflicts between scripts that happen to have the same name. +DependencyControl strictly enforces a **namespace-based file structure** for modules as well as automation macros in order to ensure there are no conflicts between scripts that happen to have the same name. -Automation scripts must define their namespace in the version record whereas for modules the module name (as you would use in a `require` statement) defines the namespace. +Automation scripts must define their namespace in the version record whereas for modules the module name (as you would use in a `require` statement) defines the namespace. Rules for a valid namespace: - 1. contains _at least_ one dot - 2. must **not** start or end with a dot - 3. must **not** contain series of two or more dots - 4. the character set is restricted to: `A-Z`, `a-z`, `0-9`, `.`, `_`, `-` - 5. *should* be descriptive (this is more of a guideline) +1. contains _at least_ one dot +2. must **not** start or end with a dot +3. must **not** contain series of two or more dots +4. the character set is restricted to: `A-Z`, `a-z`, `0-9`, `.`, `_`, `-` +5. _should_ be descriptive (this is more of a guideline) + +**Examples**: -__Examples__: - * `l0.ASSFoundation` - * `l0.ASSFoundation.Common` (for a separately version-controlled 'submodule') - * `l0.ASSWipe` - * `a-mo.LineCollection` +- `l0.ASSFoundation` +- `l0.ASSFoundation.Common` (for a separately version-controlled 'submodule') +- `l0.ASSWipe` +- `a-mo.LineCollection` ### File and Folder Structure The namespace of your script translates into a subtree of the **user** automation directory you can use to store your files in: + - On Windows: `%AppData%\Aegisub\automation` - On Linux: `~/.aegisub/automation` - On OSX: `~/Library/Application Support/Aegisub/automation` @@ -246,30 +250,32 @@ DependencyControl will _not outright_ refuse to work with scripts that ignore th **Automation Scripts** use the `?user/automation/autoload` directory, which has a flat file structure. You may **not** use subdirectories and your **file names must start with the namespace of your script**. Examples: - * `l0.ASSWipe.lua` - * `l0.ASSWipe.Addon.moon` + +- `l0.ASSWipe.lua` +- `l0.ASSWipe.Addon.moon` **Modules** use the `?user/automation/include` folder, which has a nested file structure. To determine the base name for your main entry point file and sub-directory, the dots in your namespace are replaced with the path separator (`\` on Windows, `/` on other platforms). **Tests** use the `?user/automation/tests/DepUnit/modules` or `?user/automation/tests/DepUnit/macros` folder depending on whether a macro or automation is being tested and mirror the directory structure of the respective `include` and `autoload` folders. Our example module _ASSFoundation_ with namespace `l0.ASSFoundation` writes (among others) the following files: - * `?user/automation/include/l0/ASSFoundation.lua` - * `?user/automation/include/l0/ASSFoundation/ClassFactory.lua` - * `?user/automation/include/l0/ASSFoundation/Draw/Bezier.lua` - * `?user/automation/tests/DepUnit/modules/l0/ASSFoundation.lua` ---------------------------------------------- +- `?user/automation/include/l0/ASSFoundation.lua` +- `?user/automation/include/l0/ASSFoundation/ClassFactory.lua` +- `?user/automation/include/l0/ASSFoundation/Draw/Bezier.lua` +- `?user/automation/tests/DepUnit/modules/l0/ASSFoundation.lua` + +--- ## The Updater Feed If you want DependencyControl auto-update your package(s) on the user's system, you'll need to supply update information in an updater feed, which is a _JSON_ file with the following layout: -*(`//` denotes a comment explaining the property above)* +_(`//` denotes a comment explaining the property above)_ ```json { - "dependencyControlFeedFormatVersion": "0.3.0", + "dependencyControlFeedFormatVersion": "0.3.0", // The version of the feed format. The current version is 0.3.0, don't touch this until further notice. "name": "line0's Aegisub Scripts", "description": "Main repository for all of line0's automation macros.", @@ -286,16 +292,16 @@ If you want DependencyControl auto-update your package(s) on the user's system, // The address where information about this repository can be found. In this case it references the baseUrl template variable and expands to "https://github.com/TypesettingTools/line0-Aegisub-Scripts". "fileBaseUrl": "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/@{channel}/@{namespace}", // A special rolling template variable. See the templates section below for more information. - + "macros": { // the section where all automation scripts tracked by this feed go. The key for each value is the namespace of the respective script. Below this level, this namespace is available as the @{namespace} and @{namespacePath} template variable "l0.ASSWipe": { /* ... */ }, "l0.Nudge": { /* ... */ } }, "modules": { - // Your modules go here. If your feed doesn't track any modules, you may omit this section (same goes for the macros object) + // Your modules go here. If your feed doesn't track any modules, you may omit this section (same goes for the macros object) "l0.ASSFoundation": { /* ... */ } - } + } ``` An automation script or module object looks like this: @@ -306,10 +312,10 @@ An automation script or module object looks like this: "author": "line0", "name": "ASSWipe", "description": "Performs script cleanup, removes unnecessary tags and lines.", - // These script information fields should be identical to the values defined in your + // These script information fields should be identical to the values defined in your // DependencyControl version record. "channels": { - // a list of update channels available for your script (think release, beta and alpha). + // a list of update channels available for your script (think release, beta and alpha). // The key is a channel name of your choice, but should make sense to the user picking one. "master": { // This example only defines one channel, which is set up to track @@ -320,26 +326,26 @@ An automation script or module object looks like this: "released": "2015-02-26", // Release date of the current script version (UTC/ISO 8601 format) "default": true, - // Marks this channel as the default channel in case the user doesn't have picked a specific one. - // Must be set to true for **exactly** one channel in the list. + // Marks this channel as the default channel in case the user doesn't have picked a specific one. + // Must be set to true for **exactly** one channel in the list. "platforms": ["Windows-x86", "Windows-x64", "OSX-x64"] - // Optional: A list of platforms you serve builds for. You should omit this property for regular scripts - // and modules that use only Lua/Moonscript and no binaries. If this property is absent, - // the platform check will be skipped. The platform names are derived from the output of - // ffi.os()-ffi.arch() in luajit. + // Optional: A list of platforms you serve builds for. You should omit this property for regular scripts + // and modules that use only Lua/Moonscript and no binaries. If this property is absent, + // the platform check will be skipped. The platform names are derived from the output of + // ffi.os()-ffi.arch() in luajit. "files": [ // A list of files installed by your script. { "name": ".lua", - // the file name relative to the path assigned to the script by your namespace choice - // (see 3. Namespaces and Paths for more information). Available as the @{fileName} template variable + // the file name relative to the path assigned to the script by your namespace choice + // (see 3. Namespaces and Paths for more information). Available as the @{fileName} template variable // for use in the url field below. "url": "@{fileBaseUrl}@{fileName}", - // URL from which the **raw** file can be downloaded from (no archives, no javascript - // redirects, etc...). In this case the templates expand to + // URL from which the **raw** file can be downloaded from (no archives, no javascript + // redirects, etc...). In this case the templates expand to // "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/l0.ASSWipe.lua" "sha1": "A7BD1C7F0E776BA3010B1448F22DE6528F73B077" - // The SHA-1 hash of the file being currently served under that url. Will be checked + // The SHA-1 hash of the file being currently served under that url. Will be checked // against the downloaded file, so it must always be present and valid or the update process // will fail on the user's end. }, @@ -356,13 +362,13 @@ An automation script or module object looks like this: "url": "@{fileBaseUrl}@{fileName}", "sha1": "0B4E0511116355D4A11C2EC75DF7EEAD0E14DE9F", "platform": "Windows-x86" - // Optional. When this property is present, the file will only be downloaded to the users + // Optional. When this property is present, the file will only be downloaded to the users // computer if his platform matches to this value. } ], "requiredModules": [ - // an exhaustive list of modules required by this script. Must be identical to the required - // module entries in your DependencyControl record, but you may not use short style here. + // an exhaustive list of modules required by this script. Must be identical to the required + // module entries in your DependencyControl record, but you may not use short style here. // (see 2. Usage for Automation Scripts for more information) { "moduleName": "a-mo.LineCollection", @@ -370,7 +376,7 @@ An automation script or module object looks like this: "url": "https://github.com/torque/Aegisub-Motion", "version": "1.0.1", "feed": "@{feed:a-mo}" - }, + }, { "moduleName": "l0.ASSFoundation", "name": "ASSFoundation", @@ -385,8 +391,8 @@ An automation script or module object looks like this: } }, "changelog": { - // a change log that allows users to see what's new in this and previous versions. The changelog - // is shared between all channels. Only the entries with a version number equal or below + // a change log that allows users to see what's new in this and previous versions. The changelog + // is shared between all channels. Only the entries with a version number equal or below // the version the user just updated to will be displayed. "0.1.0": [ "Sync with ASSFoundation changes", @@ -410,34 +416,37 @@ Full _JSON Schema_ documents (which you can use to validate your feeds) are prov To make maintaining an update feed easier, you can use several template variables that will be expanded when used inside string values (but **not** keys). -**Regular Variables**: These reference a specific key or value and are available at the same depth and further down the tree from the point on where they were created. +**Regular Variables**: These reference a specific key or value and are available at the same depth and further down the tree from the point on where they were created. Variables extracted at the **same depth** are expanded in a specific order. As a consequence only references to variables of lower order are expanded in values that are assigned to a variable themselves. _Depth 1:_ Feed Information - 1. `@{feedName}`: The name of the feed - 2. `@{baseUrl}`: The baseUrl field - 3. `@{feed:}`: A reference to a feed URL in the knownFeeds table + +1. `@{feedName}`: The name of the feed +2. `@{baseUrl}`: The baseUrl field +3. `@{feed:}`: A reference to a feed URL in the knownFeeds table _Depth 3:_ Script Information - 1. `@{namespace}`: the script namespace - 2. `@{namespacePath}`: the script namespace with all `.` replaced by `/` - 3. `@{scriptName}`: the script name + +1. `@{namespace}`: the script namespace +2. `@{namespacePath}`: the script namespace with all `.` replaced by `/` +3. `@{scriptName}`: the script name _Depth 5:_ Version Information - 1. `@{channel}`: the channel name of this version record - 2. `@{version}`: the version number as a SemVer string + +1. `@{channel}`: the channel name of this version record +2. `@{version}`: the version number as a SemVer string _Depth 7:_ File Information - 1. `@{platform}`: the platform defined for this file, otherwise an empty string - 2. `@{fileName}`: the file name -**"Rolling" Variables**: These variables can be defined at any depth in the JSON tree and are continuously expanded using the variables available. You can reference a rolling variable in itself, which will substitute the template for the contents the variable had at the parent-level. +1. `@{platform}`: the platform defined for this file, otherwise an empty string +2. `@{fileName}`: the file name -Right now there's only one such variable: `@{fileBaseUrl}`, which you can use to construct the URL to a file using the template variables available. +**"Rolling" Variables**: These variables can be defined at any depth in the JSON tree and are continuously expanded using the variables available. You can reference a rolling variable in itself, which will substitute the template for the contents the variable had at the parent-level. -For an example to serve updates from the HEAD of a GitHub repository main branch, see [here](https://github.com/TypesettingTools/line0-Aegisub-Scripts/blob/master/DependencyControl.json). An example that shows a feed making use of tagged releases is [also available](https://github.com/TypesettingTools/ASSFoundation/blob/master/DependencyControl.json). +Right now there's only one such variable: `@{fileBaseUrl}`, which you can use to construct the URL to a file using the template variables available. +For an example to serve updates from the HEAD of a GitHub repository main branch, see [here](https://github.com/TypesettingTools/line0-Aegisub-Scripts/blob/master/DependencyControl.json). An example that shows a feed making use of tagged releases is [also available](https://github.com/TypesettingTools/ASSFoundation/blob/master/DependencyControl.json). ## Reference @@ -445,148 +454,149 @@ This section is currently both incomplete and outdated. Sorry about that. ### DependencyControl -__DependencyControl{*tbl* [requiredModules]={}, *str* :name=script_name, *str* :description=script_description, *str* :author=script_author, *str* :url, *str* :version, *str* :moduleName, *str* [:configFile], *string* [:namespace]} --> *obj* DependencyControlRecord__ +**DependencyControl{_tbl_ [requiredModules]={}, _str_ :name=script*name, \_str* :description=script*description, \_str* :author=script*author, \_str* :url, _str_ :version, _str_ :moduleName, _str_ [:configFile], _string_ [:namespace]} --> _obj_ DependencyControlRecord** The constructor for a DependencyControl record. Uses the table-based signature. -__Arguments:__ - - * _requiredModules_: the first and only unnamed argument. Contains all required modules, which may be either a single string for a non-version-controlled requirement or a table with the following fields: - * __*str* [moduleName/[1]]:__ the module name - * __*str* [version]:__ The minimum required version of the module. Must conform to Semantic Versioning standards. The module in question must contain a DependencyControl version record or otherwise compatible version number. - * __*str* [url]__: The URL of the site where the module can be downloaded from (will be shown to the user in error methods). - * __*str* [feed]__: The update feed used to fetch a copy of the required module when it is missing from the user's system. - * __*bool* [optional=false]__: Marks the module as an optional requirement. If the module is missing on the user's system, no error will be thrown. However, version requirements *will* be checked if the module was found. - * __*str* [name]__: Friendly module name (used for error messages). - -* _name, description, author_: Required for modules, pulled from the *script_* globals for macros. -* _version_: Must conform to [Semantic Versioning](http://semver.org/) standards. Labels and build metadata are not supported at this time -* _moduleName_: module name (as used in require statements). Required for modules, must be nil for macros. Represents the namespace of a module. -* _url_: The web site/repository URL of your script -* _feed_: The update feed for your script. -* _configFile_: Configuration file base name used by the script. Defaults to the namespace. Used for configuration services and script management purposes. +**Arguments:** + +- _requiredModules_: the first and only unnamed argument. Contains all required modules, which may be either a single string for a non-version-controlled requirement or a table with the following fields: + - **_str_ [moduleName/[1]]:** the module name + - **_str_ [version]:** The minimum required version of the module. Must conform to Semantic Versioning standards. The module in question must contain a DependencyControl version record or otherwise compatible version number. + - **_str_ [url]**: The URL of the site where the module can be downloaded from (will be shown to the user in error methods). + - **_str_ [feed]**: The update feed used to fetch a copy of the required module when it is missing from the user's system. + - **_bool_ [optional=false]**: Marks the module as an optional requirement. If the module is missing on the user's system, no error will be thrown. However, version requirements _will_ be checked if the module was found. + - **_str_ [name]**: Friendly module name (used for error messages). + +- _name, description, author_: Required for modules, pulled from the \_script\_\_ globals for macros. +- _version_: Must conform to [Semantic Versioning](http://semver.org/) standards. Labels and build metadata are not supported at this time +- _moduleName_: module name (as used in require statements). Required for modules, must be nil for macros. Represents the namespace of a module. +- _url_: The web site/repository URL of your script +- _feed_: The update feed for your script. +- _configFile_: Configuration file base name used by the script. Defaults to the namespace. Used for configuration services and script management purposes. #### Methods -__:checkVersion(*str/num* version, *str* [precision = "patch"]) --> *bool* moduleUpToDate, *str* error__ -Returns true if the version number of the record is greater than or equal to __version__. Reduce the __precision__ to `minor` or `major` to also return true for lower patch or minor versions respectively. If the version can't be parsed it returns nil and and error message. +**:checkVersion(_str/num_ version, _str_ [precision = "patch"]) --> _bool_ moduleUpToDate, _str_ error** -__:checkOptionalModules(*tbl* modules) --> *bool* result, *str* errorMessage__ +Returns true if the version number of the record is greater than or equal to **version**. Reduce the **precision** to `minor` or `major` to also return true for lower patch or minor versions respectively. If the version can't be parsed it returns nil and and error message. -Returns true if the optional __modules__ have been loaded, where __modules__ is a list of module names. If one or more of the modules are missing it returns false and an error message. +**:checkOptionalModules(_tbl_ modules) --> _bool_ result, _str_ errorMessage** -__:getConfigFileName() --> *str* fileName__ +Returns true if the optional **modules** have been loaded, where **modules** is a list of module names. If one or more of the modules are missing it returns false and an error message. + +**:getConfigFileName() --> _str_ fileName** Returns a full path to the config file proposed for this script by DependencyControl. Uses the configFile argument passed to the constructor which defaults to the script namespace. The path is subject to user configuration and defaults to "?user\config". The file ending is always .json, because why would you use any other format? The rationale for this function is to keep all macro and module configuration files neatly in one spot and make them discoverable for other scripts (through the DependencyControl config file). -__:getConfigHandler([defaults], [section], [noLoad]) => *obj* ConfigHandler__ +**:getConfigHandler([defaults], [section], [noLoad]) => _obj_ ConfigHandler** Returns a ConfigHandler (see [ConfigHandler Documentation](#FIXME)) attached to the config file configured for this script. -__:getLogger(*tbl* args) => *obj* Logger__ +**:getLogger(_tbl_ args) => _obj_ Logger** Returns a Logger (see [Logger Documentation](#FIXME)) preconfigured for this script. Trace level and config file preference default to user-configurable values. Log file name and prefix are based on namespace and script name. -__:getVersionNumber(*str/num* versionString) --> *int/bool* version, *str* error__ +**:getVersionNumber(_str/num_ versionString) --> _int/bool_ version, _str_ error** Takes a SemVer string and converts it into a version number. If parsing the version string fails it returns false and an error message instead. -__:getVersionString(*int* [version=@version]) --> *str* versionString__ +**:getVersionString(_int_ [version=@version]) --> _str_ versionString** Returns a version (by default the script version) as a SemVer string. -__:getConfigFileName() --> *str* configFileName__ +**:getConfigFileName() --> _str_ configFileName** Generates and returns a full path to the registered config file name for the module. -__:loadConfig(*bool* [importRecord], *bool* [forceReloadGlobal]) --> *bool* shouldWriteConfig, *bool* firstInit__ +**:loadConfig(_bool_ [importRecord], _bool_ [forceReloadGlobal]) --> _bool_ shouldWriteConfig, _bool_ firstInit** -Loads global DependencyControl and per-script configuration from the DependencyControl configuration file. If __importRecord__ is true, the version record information of a DependencyControl record will be (temporarily) overwritten by the values contained in the configuration file. -Global configuration is only loaded on first run or if __forceReloadGlobal__ is true. +Loads global DependencyControl and per-script configuration from the DependencyControl configuration file. If **importRecord** is true, the version record information of a DependencyControl record will be (temporarily) overwritten by the values contained in the configuration file. +Global configuration is only loaded on first run or if **forceReloadGlobal** is true. The first return result indicates there are changes to be written to the config file, the second result returns true if the config file was only just created. _Intended for internal use._ -__:loadModule(*tbl* module, *bool* [usePrivate]) --> *tbl* moduleRef__ +**:loadModule(_tbl_ module, _bool_ [usePrivate]) --> _tbl_ moduleRef** -Loads and returns single module and only errors out in case of module errors. Intended for internal use. If __usePrivate__ is true, a private copy of the module is loaded instead. +Loads and returns single module and only errors out in case of module errors. Intended for internal use. If **usePrivate** is true, a private copy of the module is loaded instead. -__:moveFile(*str* src, *str* dest) --> *bool* success, *str* error__ +**:moveFile(_str_ src, _str_ dest) --> _bool_ success, _str_ error** -Moves a file from __source__ to __destination__ (where both are full file names). Returns true on success or false and error message on failure. +Moves a file from **source** to **destination** (where both are full file names). Returns true on success or false and error message on failure. -__:register(*tbl* selfRef, extraUnitTestArgs...) --> *tbl* selfRef__ +**:register(_tbl_ selfRef, extraUnitTestArgs...) --> _tbl_ selfRef** Replaces dummy reference written to the global LOADED_MODULES table at DependencyControl object creation time with a reference to this module. -Also automatically registers unit tests for this module, passing in any __extraUnitTestArgs__ +Also automatically registers unit tests for this module, passing in any **extraUnitTestArgs** The purpose of this construct is to allow circular references between modules. Limitations apply: the modules in question may not use each other during construction/setup of each module (for obvious reasons). Call this method as replacement for returning your module. -__:registerMacro(*str* [name=@name], *str* [description=@description], *func* processing_function, *func* [validation_function], *func* is_active_function, *bool|string* [submenu=false])__ +**:registerMacro(_str_ [name=@name], _str_ [description=@description], _func_ processing*function, \_func* [validation_function], _func_ is*active_function, \_bool|string* [submenu=false])** Alternative Signature: -__:registerMacro(*func* processing_function, *func* [validation_function], *func* is_active_function, *bool|string* [submenu=false])__ +**:registerMacro(_func_ processing*function, \_func* [validation_function], _func_ is*active_function, \_bool|string* [submenu=false])** Registers a single macro using script name and description by default. -Use __submenu__ to specify a submenu name to use for this macro or set it to `true` to use the automation script name. +Use **submenu** to specify a submenu name to use for this macro or set it to `true` to use the automation script name. -If the script entry in the DependencyControl configuration file contains a __customMenu__ property, the macro will be placed in the specified menu. Do note that that this setting is for *user customization* and not to be changed without the user's consent. +If the script entry in the DependencyControl configuration file contains a **customMenu** property, the macro will be placed in the specified menu. Do note that that this setting is for _user customization_ and not to be changed without the user's consent. For the other arguments, please refer to the [aegisub.register_macro](http://docs.aegisub.org/latest/Automation/Lua/Registration/#aegisub.register_macro) API documentation. -__:registerMacros(*tbl* macros, *bool|string* [submenuDefault=true])__ +**:registerMacros(_tbl_ macros, _bool|string_ [submenuDefault=true])** -Registers multiple macros, where __macros__ is a list of tables containing the arguments to a __:registerMacro()__ call for each automation menu entry. a single macro using script name and description by default. -Use __submenuDefault__ to specify a submenu all macros will be placed in unless overridden on a per-macro basis. Defaults to `true` which causes the automation script name to be used as the submenu name. +Registers multiple macros, where **macros** is a list of tables containing the arguments to a **:registerMacro()** call for each automation menu entry. a single macro using script name and description by default. +Use **submenuDefault** to specify a submenu all macros will be placed in unless overridden on a per-macro basis. Defaults to `true` which causes the automation script name to be used as the submenu name. -__:registerTests(unitTestArgs...)__ +**:registerTests(unitTestArgs...)** -Registers unit tests for automation modules, passing in any of specified __unitTestArgs__. Registration of modules is done automatically upon calling __:register__ +Registers unit tests for automation modules, passing in any of specified **unitTestArgs**. Registration of modules is done automatically upon calling **:register** -__:requireModules([modules=@requiredModules], *bool* [forceUpdate], *bool* [updateMode], *tbl* [addFeeds={@feed})] --> ...__ +**:requireModules([modules=@requiredModules], _bool_ [forceUpdate], _bool_ [updateMode], _tbl_ [addFeeds={@feed})] --> ...** Loads the modules required by this script and returns a reference for every requirement in the order they were supplied by the user. If an optional module is not found, nil is returned. -The updater will try to download copies of modules that are missing or outdated on the user's system. The __addFeeds__ parameter can be used to supply additional feeds to search. If missing/outdated requirements can't be fetched, the method will throw an error in normal mode or false and an error message in __update mode__. +The updater will try to download copies of modules that are missing or outdated on the user's system. The **addFeeds** parameter can be used to supply additional feeds to search. If missing/outdated requirements can't be fetched, the method will throw an error in normal mode or false and an error message in **update mode**. -Use __forceUpdate__ to override update intervals and perform update checks for all required modules, even if requirements are satisfied. +Use **forceUpdate** to override update intervals and perform update checks for all required modules, even if requirements are satisfied. -__:writeConfig(*bool* [writeLocal=true], *bool* [writeGlobal=true], *bool* [concert]]__ +**:writeConfig(_bool_ [writeLocal=true], _bool_ [writeGlobal=true], _bool_ [concert]]** -Writes __global__ and per-module __local__ configuration. If __concert__ is true, concerted writing will be used to update the configuration of all DependencyControl hosted by any given macro/environment at once. See ConfigHandler documentation for more information. _Intended for internal use._ +Writes **global** and per-module **local** configuration. If **concert** is true, concerted writing will be used to update the configuration of all DependencyControl hosted by any given macro/environment at once. See ConfigHandler documentation for more information. _Intended for internal use._ ### Updater #### Methods -__:getUpdaterErrorMsg(*int* [code], *str* targetName, ...) --> *str* errorMsg__ +**:getUpdaterErrorMsg(_int_ [code], _str_ targetName, ...) --> _str_ errorMsg** -Used to turn an updater return __code__ into a human-readable error message. The __name__ of the updated component and other format string parameters are passed into the function. +Used to turn an updater return **code** into a human-readable error message. The **name** of the updated component and other format string parameters are passed into the function. VarArgs: - 1. __*bool* isModule__: True when component is a module, false when it is an automation script/macro - 2. __*bool* isFetch__: True when we are fetching a missing module, false when updating - 3. __extError__: Extended error information as returned by the _:update()_ method +1. **_bool_ isModule**: True when component is a module, false when it is an automation script/macro +2. **_bool_ isFetch**: True when we are fetching a missing module, false when updating +3. **extError**: Extended error information as returned by the _:update()_ method -__:getUpdaterLock(*bool* [doWait], *int* [waitTimeout=(user config)]) --> *bool* result, *str* runningHost__ +**:getUpdaterLock(_bool_ [doWait], _int_ [waitTimeout=(user config)]) --> _bool_ result, _str_ runningHost** Locks the updater to the current macro/environment. Since all automation scripts load in parallel we have to make sure multiple automation scripts don't all update/fetch the same dependencies at once multiple times. The solution is to only let one updater operate at a time. The others will wait their turn and recheck if their required modules were fetched in the meantime. -If __doWait__ is true, the function will wait until the updater is unlocked or __waitTimeout__ has passed. It will then get the lock and return true. If __doWait__ is false, the function will return immediately (true on success, false if another updater has the lock). _Intended for internal use_. +If **doWait** is true, the function will wait until the updater is unlocked or **waitTimeout** has passed. It will then get the lock and return true. If **doWait** is false, the function will return immediately (true on success, false if another updater has the lock). _Intended for internal use_. -__:releaseUpdaterLock()__ +**:releaseUpdaterLock()** Makes an updater host (macro) release its lock on the Updater if it has one. See _:getUpdaterLock_ for more information -__:update(*bool* [force], *tbl* [addFeeds], *bool* [tryAllFeeds=auto]) --> *int* resultCode, *str* extError__ +**:update(_bool_ [force], _tbl_ [addFeeds], _bool_ [tryAllFeeds=auto]) --> _int_ resultCode, _str_ extError** -Runs the updater on this automation script or module. This includes recursively updating all required modules. When __force__ is true, required modules will skip their update interval check. +Runs the updater on this automation script or module. This includes recursively updating all required modules. When **force** is true, required modules will skip their update interval check. -By default, the updater will process all suitable feeds until one feed confirms the script to be up-to-date (unless configured otherwise by the user or if we are looking for updates to an outdated component). Set __tryAllFeeds__ to true to check all feeds until an update is found. You can also supply __additional candidate feeds__. +By default, the updater will process all suitable feeds until one feed confirms the script to be up-to-date (unless configured otherwise by the user or if we are looking for updates to an outdated component). Set **tryAllFeeds** to true to check all feeds until an update is found. You can also supply **additional candidate feeds**. Returns a result code (0: up-to-date, 1: update performed, <=-1: error) and extended error information which can be fed into _:getUpdaterErrorMsg()_ to get a descriptive error message. @@ -610,52 +620,102 @@ Reference documentation for the UnitTestSuite module is available in the [source tbd -## DependencyControl Testing +## CLI -DependencyControl ships a headless test runner (`run-tests.lua`) that executes the full unit -test suite from the command line — locally or in CI — **without** an Aegisub process. An -`aegisub` global shim and bundled FFI implementations of the timer, mutex and download manager -stand in for the host application, and JSON is vendored (dkjson), so the only external -dependencies are LuaJIT and two LuaRocks modules. +DependencyControl ships a CLI launcher (`depctrl.lua`) for running tests, building release +bundles, and deploying to a local Aegisub installation — all **without** a running Aegisub +process. All commands read their package list from a feed JSON file and can operate on any +DepCtrl-managed package, not only DependencyControl itself. ### Prerequisites - - _LuaJIT_ on your `PATH`, built with `DLUAJIT_ENABLE_LUA52COMPAT` - - _LuaRocks_, configured for Lua v5.1, which _LuaJIT_ is ABI-compatible with. You may have to select the Lua version explicitly via `luarocks --lua-version=5.1` - - The [moonscript](https://luarocks.org/modules/leafo/moonscript) and [LuaFileSystem](https://luarocks.org/modules/hisham/luafilesystem) rocks, installed into that 5.1 tree: +- _LuaJIT_ on your `PATH`, built with `DLUAJIT_ENABLE_LUA52COMPAT` +- _LuaRocks_, configured for Lua v5.1, which _LuaJIT_ is ABI-compatible with. You may have to select the Lua version explicitly via `luarocks --lua-version=5.1` +- The [moonscript](https://luarocks.org/modules/leafo/moonscript), [LuaFileSystem](https://luarocks.org/modules/hisham/luafilesystem) and [argparse](https://luarocks.org/modules/mpeterv/argparse) rocks, installed into that 5.1 tree: + + ```sh + luarocks --lua-version=5.1 install moonscript + luarocks --lua-version=5.1 install luafilesystem + luarocks --lua-version=5.1 install argparse + ``` + +- Your `LUA_PATH` / `LUA_CPATH` must let `luajit` find the LuaRocks-installed modules (`luarocks --lua-version=5.1 path --bin` prints the correct values). + +General form: + +```sh +luajit depctrl.lua [options] +``` + +The feed is resolved in this order: `--feed` flag → `DependencyControl.json` in the current +working directory. All commands accept `--target-module` and `--target-macro` to restrict +processing to specific packages; without them the command operates on every package in the feed. + +### `test` — Run unit test suites + +```sh +luajit depctrl.lua test [--feed ] [--report-dir ] + [--target-module ] [--target-macro ] +``` + +Loads every matching package from the feed, runs its DepUnit test suite (if one is registered), +and writes a per-package [CTRF](https://ctrf.io) JSON report. Exit code `0` = all tested +packages passed, `1` = one or more failures or load errors. - ```sh - luarocks --lua-version=5.1 install moonscript - luarocks --lua-version=5.1 install luafilesystem - ``` +Packages without a test suite are skipped with a notice; packages that fail to load are counted +as failures. Log files and config/feed caches are written to a per-run throwaway workspace under +the system temp directory rather than touching your real Aegisub configuration. - - Your `LUA_PATH` / `LUA_CPATH` must let `luajit` find the LuaRocks-installed modules (`luarocks --lua-version=5.1 path --bin` prints the correct values). +The feed must have correct `localFileBasePath` entries so the CLI can resolve source files on +disk. -### Running +| Option | Default | Description | +| ----------------- | ------------------------------- | -------------------------------------------------------- | +| `--feed` | `DependencyControl.json` in CWD | Path to the feed JSON file | +| `--report-dir` | `ctrf/` | Directory for per-package CTRF JSON reports | +| `--target-module` | _(all modules)_ | Module namespace to test; repeatable | +| `--target-macro` | _(all macros)_ | Macro namespace to test; repeatable | -From the repository root: +### `bundle` — Build a release archive ```sh -luajit run-tests.lua +luajit depctrl.lua bundle [--feed ] [--out-dir ] + [--target-module ] [--target-macro ] ``` -The runner resolves its own location to find the bundled `modules/` directory, so it works -regardless of the current working directory. It exits `0` when every test passes and `1` otherwise. +Copies every file listed in the feed into a `dist/` subfolder of ``, then packages +`dist/` into a zip archive named `-v[--g].zip` in ``. +`dist/` is wiped and recreated on each run. The git branch and hash suffix is omitted when +HEAD is exactly on a tag. -Log files and config/feed caches are written to a per-run throwaway workspace created under -your system temp directory, rather than touching your real Aegisub configuration. Each Aegisub -path token (`?user`, `?temp`, ...) gets its own subdirectory there. Set `DEPCTRL_TEMP_DIR` to -choose a different base directory for that workspace. +| Option | Default | Description | +| ----------------- | ------------------------------- | -------------------------------------------- | +| `--feed` | `DependencyControl.json` in CWD | Path to the feed JSON file | +| `--out-dir` | CWD | Root for the `dist/` folder and the zip file | +| `--target-module` | _(all modules)_ | Restrict to this module namespace; repeatable | +| `--target-macro` | _(all macros)_ | Restrict to this macro namespace; repeatable | -### Test report +Exit code `0` = success, `1` = one or more errors. -After running, the suite writes a [CTRF](https://ctrf.io) report — a JSON test-result format -understood by ready-made CI reporters. By default it lands at `./ctrf/DependencyControl.json` -relative to the runner/repository root; pass a different path as the first CLI argument: +### `deploy` — Deploy to a local Aegisub installation ```sh -luajit run-tests.lua path/to/report.json +luajit depctrl.lua deploy [--feed ] [--out-dir ] [--clobber | --no-clobber] + [--target-module ] [--target-macro ] ``` -The same report can be produced programmatically from any `UnitTestSuite` via -`suite\writeResults(path)` (or `suite\toCtrf!` for the raw table). +Copies every file listed in the feed directly into `` using the Aegisub install layout — +macros into `/automation/autoload/`, modules into `/automation/modules/`, test files +into `/automation/tests/DepUnit/…`. Useful for testing against a locally installed Aegisub +without going through a full release build. + +| Option | Default | Description | +| ----------------- | ------------------------------- | ------------------------------------------------------ | +| `--feed` | `DependencyControl.json` in CWD | Path to the feed JSON file | +| `--out-dir` | CWD | Deployment root — typically the Aegisub user directory | +| `--clobber` | false | Overwrite existing files in the deployment directory | +| `--no-clobber` | _(default)_ | Skip files that already exist at the destination | +| `--target-module` | _(all modules)_ | Restrict to this module namespace; repeatable | +| `--target-macro` | _(all macros)_ | Restrict to this macro namespace; repeatable | + +Exit code `0` = success, `1` = one or more errors. diff --git a/depctrl.lua b/depctrl.lua index 2769a18..eb31c7e 100644 --- a/depctrl.lua +++ b/depctrl.lua @@ -1,26 +1,15 @@ #!/usr/bin/env luajit --- DependencyControl CLI launcher. --- --- Usage: luajit depctrl.lua [args...] --- --- luajit depctrl.lua test [ctrf-report-path] --- Run the unit test suite. The optional argument overrides the CTRF report --- output path (default: ctrf/DependencyControl.json next to this script). --- Exit code 0 = all tests pass, 1 = failures. --- --- luajit depctrl.lua bundle --- Build a dist/ release bundle by copying every file listed in --- DependencyControl.json to the path derived from its expanded download URL. --- dist/ is cleaned first. Exit code 0 = success, 1 = one or more warnings. - -local ffi = require "ffi" -local lfs = require "lfs" +-- DependencyControl CLI toolbox + +local ffi = require "ffi" +local lfs = require "lfs" +local argparse = require "argparse" require "moonscript" -- installs moonscript's package.moonpath loader for .moon files -- ── Path utilities ──────────────────────────────────────────────────────────── local isWindows = ffi.os == "Windows" -local pathSep = isWindows and "\\" or "/" +local pathSep = isWindows and "\\" or "/" local function dirname(path) return (path or ""):match("^(.*)[/\\][^/\\]*$") or "." @@ -31,12 +20,47 @@ local function isAbsolute(path) or path:match("^[/\\]") ~= nil -- /... or \... end -local function fileExists(path) - local f = io.open(path, "r") - if f then f:close(); return true end - return false +local function resolveAbsPath(path) + if not isAbsolute(path) then + return lfs.currentdir() .. pathSep .. path + end + return path +end + +-- ── Argument parsing ────────────────────────────────────────────────────────── + +local parser = argparse("depctrl", "DependencyControl CLI toolbox") + :epilog("See README.md for detailed instructions.") +parser:command_target("command") + +-- Selector options shared by all commands: repeat --target-module / --target-macro to pick +-- packages by namespace. With none given, a command operates on every package in the feed. +local function addTargets(cmd) + cmd:option("--target-module", "Module namespace to operate on (repeatable; default: all)") + :argname(""):count("*") + cmd:option("--target-macro", "Macro namespace to operate on (repeatable; default: all)") + :argname(""):count("*") end +local testCmd = parser:command("test", "Run the unit test suite(s) for packages in a feed") +testCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json") +testCmd:option("-r --report-dir", "Directory for per-package CTRF JSON reports"):default("ctrf") +addTargets(testCmd) + +local bundleCmd = parser:command("bundle", "Build a dist/ release bundle and zip archive") +bundleCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json") +bundleCmd:option("-o --out-dir", "Output directory; script files go into its dist/ subfolder"):default(".") +addTargets(bundleCmd) + +local deployCmd = parser:command("deploy", "Deploy files directly to an output directory") +deployCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json") +deployCmd:option("-o --out-dir", "Output directory"):default(".") +deployCmd:flag("--clobber", "Overwrite existing files (default)"):target("clobber") +deployCmd:flag("--no-clobber", "Skip files that already exist at the destination"):target("clobber"):action("store_false") +addTargets(deployCmd) + +local args = parser:parse() + -- ── Resolve the launcher directory ─────────────────────────────────────────── -- Made absolute up-front so nothing downstream can be confused by CWD changes. @@ -46,24 +70,26 @@ if launcherDir == "." then elseif not isAbsolute(launcherDir) then launcherDir = lfs.currentdir() .. pathSep .. launcherDir end --- ── Module resolution ────────────────────────────────────────────────────────── + +-- ── Module resolution ───────────────────────────────────────────────────────── -- The repo's modules/ tree is namespaced (modules/l0/…), so l0.* require paths map -- straight onto it: moonscript's loader resolves .moon via package.moonpath, the -- stock searcher the vendored .lua via package.path. No custom searcher needed. -local modulesDir = launcherDir .. pathSep .. "modules" -package.path = ("%s/?.lua;%s/?/init.lua;"):format(modulesDir, modulesDir) .. package.path -package.moonpath = ("%s/?.moon;%s/?/init.moon;"):format(modulesDir, modulesDir) .. (package.moonpath or "") + +local depCtrlModulesDir = launcherDir .. pathSep .. "modules" +package.path = ("%s/?.lua;%s/?/init.lua;"):format(depCtrlModulesDir, depCtrlModulesDir) .. package.path +package.moonpath = ("%s/?.moon;%s/?/init.moon;"):format(depCtrlModulesDir, depCtrlModulesDir) .. (package.moonpath or "") -- ── Aegisub shims ───────────────────────────────────────────────────────────── -local shims = require "l0.AegisubShims" +local shims = require "l0.AegisubShims" local aegisub = shims.aegisub -- pulled into local scope; global is set by the shim for sub-modules -- ── Shared: workspace + DepCtrl bootstrap ──────────────────────────────────── -local function setupDepCtrl(workspacePrefix) - local tempBase = shims.getPathToken("temp") - local workspace = tempBase .. pathSep .. (workspacePrefix .. "-%x"):format(os.time() % 0x100000) +local function setupDepCtrl(taskName) + local tempBase = shims.getPathToken("temp") + local workspace = tempBase .. pathSep .. ("depctrl-" .. taskName .. "-%x"):format(os.time() % 0x100000) for _, token in ipairs({ "user", "local", "data", "temp" }) do shims.setPathToken(token, workspace .. pathSep .. token) end @@ -86,183 +112,167 @@ local function setupDepCtrl(workspacePrefix) return require "l0.DependencyControl" end --- ── Command dispatch ────────────────────────────────────────────────────────── +-- ── Shared: feed loading, target filtering, source resolution ──────────────── -local cmd = arg[1] +-- Loads and expands a feed (Local mode resolves each file's on-disk source path). +local function loadFeed(feedPath) + local UpdateFeed = require "l0.DependencyControl.UpdateFeed" + local feed = UpdateFeed(nil, false, feedPath) + local ok, err = feed:loadFile(feedPath, UpdateFeed.ExpansionMode.Local) + if not ok then + io.stderr:write("Error loading feed '" .. feedPath .. "': " .. tostring(err) .. "\n") + os.exit(1) + end + return feed +end --- ─── test ───────────────────────────────────────────────────────────────────── -if cmd == "test" then - local DepCtrl = setupDepCtrl("depctrl-tests") - local suite = require "l0.DependencyControl.Tests" +-- Builds a ScriptTargetFilter from the --target-module/--target-macro selectors. With no +-- selectors it includes everything; otherwise just the named packages, by type. +local function buildFilter(cliArgs) + local Common = require "l0.DependencyControl.Common" + local filter = require("l0.DependencyControl.ScriptTargetFilter")() + local mods, macros = cliArgs.target_module or {}, cliArgs.target_macro or {} + if #mods == 0 and #macros == 0 then return filter:includeAll() end + for _, ns in ipairs(mods) do filter:include(Common.ScriptType.Module, ns) end + for _, ns in ipairs(macros) do filter:include(Common.ScriptType.Automation, ns) end + return filter +end - suite:import(DepCtrl) - local success = suite:run() +-- Builds a `requireId -> source path` map from every file in the feed and registers it as a +-- fallback module searcher (after the standard ones), so packages whose source layout isn't +-- namespaced (e.g. a flat repo root) still resolve straight from the checkout. Namespaced +-- repos keep resolving via the stock moonpath/path searchers, which run first. +local function registerFeedSearcher(feed) + local moonbase = require "moonscript.base" - local reportPath = arg[2] or (launcherDir .. pathSep .. "ctrf" .. pathSep .. "DependencyControl.json") - if not isAbsolute(reportPath) then - reportPath = lfs.currentdir() .. pathSep .. reportPath:gsub("^%.[/\\]", "") + -- ".moon" -> "", "/Common.moon" -> ".Common", "/test/Common.moon" -> ".test.Common" + local function leafSuffix(name) + return (name:gsub("%.moon$", ""):gsub("%.lua$", ""):gsub("/", ".")) end - local wrote, writeErr = suite:writeResults(reportPath) - io.stderr:write(wrote and ("\nWrote CTRF report to " .. reportPath .. "\n") - or ("\nWarning: couldn't write CTRF report: " .. tostring(writeErr) .. "\n")) - io.stderr:write(success and "\nAll DependencyControl tests passed.\n" - or "\nDependencyControl tests FAILED.\n") - os.exit(success and 0 or 1) - --- ─── bundle ─────────────────────────────────────────────────────────────────── -elseif cmd == "bundle" then - setupDepCtrl("depctrl-bundle") - - local Common = require "l0.DependencyControl.Common" - local FileOps = require "l0.DependencyControl.FileOps" - local UpdateFeed = require "l0.DependencyControl.UpdateFeed" - local ZipArchiver = require "l0.DependencyControl.ZipArchiver" - local feedPath = launcherDir .. pathSep .. "DependencyControl.json" - - -- Load and expand the feed without touching the network. - local feed = UpdateFeed(feedPath, false) - local ok, err = feed:loadFile(feedPath) - if not ok then - io.stderr:write("Error loading feed: " .. tostring(err) .. "\n") - os.exit(1) + local sourceById = {} + for file, _, pkg in feed:walkFiles() do + local src = file.localFilePath + if src then + local base = file.type == "test" and (pkg.namespace .. ".test") or pkg.namespace + local id = base .. leafSuffix(file.name) + sourceById[id] = sourceById[id] or src -- first channel wins; sources are channel-agnostic + end end - local feedFileBaseUrl = feed.data.fileBaseUrl or "" - if feedFileBaseUrl == "" then - io.stderr:write("Error: feed has no fileBaseUrl — cannot determine source paths\n") - os.exit(1) + table.insert(package.loaders or package.searchers, function(modName) + local src = sourceById[modName] + if not src then return "\n\tno source mapped in feed for '" .. modName .. "'" end + if src:match("%.moon$") then + local chunk, err = moonbase.loadfile(src) + if not chunk then error("error compiling " .. src .. ": " .. tostring(err)) end + return chunk + end + return assert(loadfile(src)) + end) + + return sourceById +end + +-- ── Command dispatch ────────────────────────────────────────────────────────── + +-- ─── test ───────────────────────────────────────────────────────────────────── +if args.command == "test" then + -- Resolve every test suite by its source require identifier, ".test". + -- Standard searchers resolve namespaced repos (e.g. DepCtrl's own modules/ tree); + -- the feed searcher registered below catches non-namespaced ones. Set before any + -- package is required, since requiring a managed module triggers test registration. + DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER = function(scriptType, namespace) + return namespace .. ".test" end - -- ── Clean and recreate dist/ ────────────────────────────────────────────── - -- All managed file operations go through DependencyControl's own FileOps so - -- the launcher stays a thin wrapper around the library it ships. + setupDepCtrl("tests") + local FileOps = require "l0.DependencyControl.FileOps" - local distDir = launcherDir .. pathSep .. "dist" - FileOps.remove(distDir, true) - FileOps.mkdir(distDir, false, true) + local feedPath = resolveAbsPath(args.feed) + local feed = loadFeed(feedPath) - -- ── Copy files ──────────────────────────────────────────────────────────── - -- Source path: the file's expanded URL has the feed-level fileBaseUrl prefix - -- stripped to a versioned path (e.g. "v0.7.0-alpha/modules/Foo.moon"); dropping - -- the leading version/channel segment yields the repo-relative source path. - -- Destination: the file's install layout, derived from its namespace via - -- Common.getFileDeployPath (autoload/ for macros, include/ for modules, tests/DepUnit/… - -- for test files), so dist/ mirrors an Aegisub automation directory and the - -- bundle is a drop-in extract. - - local function escapePat(s) - return (s:gsub("([%.%+%-%*%?%[%]%^%$%(%)%%])", "%%%1")) + local selected = {} + for pkg, scriptType in feed:walkPackages(buildFilter(args)) do + selected[#selected + 1] = { namespace = pkg.namespace, scriptType = scriptType } + end + table.sort(selected, function(a, b) return a.namespace < b.namespace end) + if #selected == 0 then + io.stderr:write("No packages matched in feed '" .. feedPath .. "'.\n") + os.exit(1) end - local baseUrlPat = "^" .. escapePat(feedFileBaseUrl) .. "(.+)$" - -- Install paths from Common.getFileDeployPath are absolute under ?user/automation; strip - -- that root to get the path relative to dist/. Normalize separators to '/'. - local autoRoot = aegisub.decode_path("?user/automation"):gsub("\\", "/") - - local fileCount, warnCount = 0, 0 - - for _, section in ipairs({ "macros", "modules" }) do - local pkgs = feed.data[section] - if not pkgs then goto nextSection end - local scriptType = section == "macros" and Common.ScriptType.Automation - or Common.ScriptType.Module - - for namespace, pkg in pairs(pkgs) do - for channelName, channel in pairs(pkg.channels or {}) do - for _, file in ipairs(channel.files or {}) do - local url = file.url - if not url then - io.stderr:write((" warn: %s/%s/%s has no url\n") - :format(namespace, channelName, tostring(file.name))) - warnCount = warnCount + 1 - goto nextFile - end - - -- Source: strip the feed base URL, then the leading version/channel segment. - local afterBase = url:match(baseUrlPat) - if not afterBase then - io.stderr:write((" warn: URL not under feedFileBaseUrl, skipping:\n %s\n"):format(url)) - warnCount = warnCount + 1 - goto nextFile - end - - local relPath = afterBase:match("^[^/]+/(.+)$") - if not relPath then - io.stderr:write((" warn: cannot strip version prefix from: %s\n"):format(afterBase)) - warnCount = warnCount + 1 - goto nextFile - end - - local srcPath = launcherDir .. pathSep .. relPath:gsub("/", pathSep) - if not fileExists(srcPath) then - io.stderr:write((" warn: source not found: %s\n"):format(srcPath)) - warnCount = warnCount + 1 - goto nextFile - end - - -- Destination: install layout relative to dist/. - local installRel = Common:getFileDeployPath(namespace, scriptType, file.name, file.type or "script") - :gsub("\\", "/"):sub(#autoRoot + 2) - local dstPath = distDir .. pathSep .. installRel:gsub("/", pathSep) - - FileOps.mkdir(dstPath, true, true) -- ensure the target's parent dir exists - local copied, copyErr = FileOps.copy(srcPath, dstPath) - if copied then - io.stdout:write((" %s → dist/%s\n"):format(relPath, installRel)) - fileCount = fileCount + 1 - else - io.stderr:write((" error copying %s: %s\n"):format(relPath, tostring(copyErr))) - warnCount = warnCount + 1 - end - - ::nextFile:: - end - end + registerFeedSearcher(feed) + + local reportDir = resolveAbsPath(args.report_dir) + local ran, skipped, failed = 0, 0, 0 + + for _, pkg in ipairs(selected) do + local ns = pkg.namespace + local okRequire, mod = pcall(require, ns) + local record = okRequire and type(mod) == "table" and mod.version or nil + + if not okRequire then + io.stderr:write(("! %s: failed to load (%s)\n"):format(ns, tostring(mod))) + failed = failed + 1 + elseif not (record and record.__class and record.__class.__name == "DependencyControl") then + io.stderr:write(("~ %s: not a DependencyControl-managed package, skipping\n"):format(ns)) + skipped = skipped + 1 + elseif record.haveTestSuite == false then + io.stderr:write(("~ %s: no test suite found (%s), skipping\n"):format(ns, tostring(record.testSuiteLoadError))) + skipped = skipped + 1 + elseif not record.testSuiteInitialized then + io.stderr:write(("! %s: test suite failed to initialize (%s)\n"):format(ns, tostring(record.testSuiteInitializeError))) + failed = failed + 1 + else + io.stdout:write(("\n=== Testing %s ===\n"):format(ns)) + local success = record.tests:run() + ran = ran + 1 + if not success then failed = failed + 1 end + + local reportPath = FileOps.joinPath(reportDir, ns .. ".json") + local wrote, writeErr = record.tests:writeResults(reportPath) + io.stderr:write(wrote and ("Wrote CTRF report to " .. reportPath .. "\n") + or ("Warning: couldn't write CTRF report for " .. ns .. ": " .. tostring(writeErr) .. "\n")) end - ::nextSection:: end - -- ── Create the zip archive ───────────────────────────────────────────────── - -- Named DependencyControl-v; when HEAD is not on a tag, a - -- --g suffix is appended (git-describe style). + io.stdout:write(("\n%d package(s) tested, %d skipped, %d failed.\n"):format(ran, skipped, failed)) + os.exit(failed > 0 and 1 or 0) - local function defaultChannelVersion(pkg) - local fallback - for _, ch in pairs(pkg.channels or {}) do - fallback = fallback or ch.version - if ch.default then return ch.version end - end - return fallback - end +-- ─── bundle ─────────────────────────────────────────────────────────────────── +elseif args.command == "bundle" then + local feedPath = resolveAbsPath(args.feed) + local outputDir = resolveAbsPath(args.out_dir) - local mainPkg = feed.data.modules and feed.data.modules["l0.DependencyControl"] - local mainVersion = mainPkg and defaultChannelVersion(mainPkg) - if not mainVersion then - io.stderr:write("Error: couldn't determine l0.DependencyControl version from feed\n") - os.exit(1) - end + setupDepCtrl("bundle") - local function git(args) - local h = io.popen(('git -C "%s" %s 2>&1'):format(launcherDir, args)) - if not h then return nil end - local out = (h:read("*a") or ""):gsub("%s+$", "") - local success = h:close() - return success and out ~= "" and out or nil - end + local FileOps = require "l0.DependencyControl.FileOps" + local ZipArchiver = require "l0.DependencyControl.ZipArchiver" + local GitRepository = require "l0.DependencyControl.GitRepository" + + local feed = loadFeed(feedPath) + local filter = buildFilter(args) - local suffix = "" - if not git("describe --exact-match --tags HEAD") then -- HEAD is not on a tag - local branch = git("rev-parse --abbrev-ref HEAD") or "unknown" - local hash = git("rev-parse --short=7 HEAD") or "0000000" - suffix = ("-%s-g%s"):format(branch, hash) + local distDir = outputDir .. pathSep .. "dist" + FileOps.remove(distDir, true) + FileOps.mkdir(distDir, false, true) + + local fileCount, errCount = feed:deployFiles(distDir, filter, false) + + -- Name the archive after the feed's headline module (DepCtrl's own feed) where present, + -- otherwise fall back to the first module version so other feeds still bundle. + local mainVersion = feed:getModuleVersion("l0.DependencyControl") + if not mainVersion then + for ns in pairs(feed.data.modules or {}) do + mainVersion = feed:getModuleVersion(ns) + if mainVersion then break end + end + mainVersion = mainVersion or "0.0.0" end - local zipName = ("DependencyControl-v%s%s.zip"):format(mainVersion, suffix) - local zipPath = launcherDir .. pathSep .. zipName + local suffix = GitRepository(feed.feedDir):getVersionSuffix() + local zipPath = outputDir .. pathSep .. (feed.data.name .. "-v%s%s.zip"):format(mainVersion, suffix) - -- Archive the whole dist/ tree via DependencyControl's own ZipArchiver, which - -- uses each platform's stock tooling and emits spec-compliant forward-slash - -- entries (per-platform details live in the module). local zipOk = false if fileCount > 0 then local success, archiveErr = ZipArchiver(zipPath):addDirectory(distDir):write() @@ -274,18 +284,24 @@ elseif cmd == "bundle" then end local status = fileCount > 0 and "Bundle complete" or "Bundle produced no files" - io.stdout:write(("\n%s: %d file(s) copied, %d warning(s) → %s\n") - :format(status, fileCount, warnCount, distDir)) - if zipOk then - io.stdout:write(("Archive: %s\n"):format(zipPath)) - end - os.exit(warnCount > 0 and 1 or 0) - --- ─── usage ──────────────────────────────────────────────────────────────────── -else - io.stderr:write(("Usage: luajit %s [args...]\n"):format(arg[0] or "depctrl.lua")) - io.stderr:write("Commands:\n") - io.stderr:write(" test [ctrf-report-path] Run the unit test suite\n") - io.stderr:write(" bundle Build the dist/ release bundle\n") - os.exit(1) + io.stdout:write(("\n%s: %d file(s) in %s, %d error(s)\n"):format(status, fileCount, distDir, errCount)) + if zipOk then io.stdout:write(("Archive: %s\n"):format(zipPath)) end + os.exit(errCount > 0 and 1 or 0) + +-- ─── deploy ─────────────────────────────────────────────────────────────────── +elseif args.command == "deploy" then + local feedPath = resolveAbsPath(args.feed) + local outputDir = resolveAbsPath(args.out_dir) + local clobber = args.clobber == true + + setupDepCtrl("deploy") + + local feed = loadFeed(feedPath) + local filter = buildFilter(args) + + local fileCount, errCount = feed:deployFiles(outputDir, filter, clobber) + + local status = fileCount > 0 and "Deploy complete" or "Deploy produced no files" + io.stdout:write(("\n%s: %d file(s) deployed to %s, %d error(s)\n"):format(status, fileCount, outputDir, errCount)) + os.exit(errCount > 0 and 1 or 0) end diff --git a/modules/l0/DependencyControl.moon b/modules/l0/DependencyControl.moon index 71f6b40..5c00b21 100644 --- a/modules/l0/DependencyControl.moon +++ b/modules/l0/DependencyControl.moon @@ -28,24 +28,44 @@ provideBundled "l0.dkjson", {"json", "dkjson"} provideBundled "l0.DependencyControl.TerribleMutex", {"BM.BadMutex"}, "DEPCTRL_PREFER_FFI_MUTEX" provideBundled "l0.DependencyControl.DownloadManager", {"DM.DownloadManager"}, "DEPCTRL_PREFER_FFI_DOWNLOADER" -Logger = require "l0.DependencyControl.Logger" -UpdateFeed = require "l0.DependencyControl.UpdateFeed" +Common = require "l0.DependencyControl.Common" ConfigHandler = require "l0.DependencyControl.ConfigHandler" +ConfigView = require "l0.DependencyControl.ConfigView" +Crypto = require "l0.DependencyControl.Crypto" +Downloader = require "l0.DependencyControl.Downloader" +Enum = require "l0.DependencyControl.Enum" +EventEmitter = require "l0.DependencyControl.EventEmitter" FileOps = require "l0.DependencyControl.FileOps" -Updater = require "l0.DependencyControl.Updater" -UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +GitRepository = require "l0.DependencyControl.GitRepository" +Lock = require "l0.DependencyControl.Lock" +Logger = require "l0.DependencyControl.Logger" Record = require "l0.DependencyControl.Record" +Stub = require "l0.DependencyControl.Stub" +Timer = require "l0.DependencyControl.Timer" +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +UpdateFeed = require "l0.DependencyControl.UpdateFeed" +Updater = require "l0.DependencyControl.Updater" class DependencyControl extends Record + @Common = Common @ConfigHandler = ConfigHandler - @UpdateFeed = UpdateFeed + @ConfigView = ConfigView + @Crypto = Crypto + @Downloader = Downloader + @Enum = Enum + @EventEmitter = EventEmitter + @FileOps = FileOps + @GitRepository = GitRepository + @Lock = Lock @Logger = Logger + @Record = Record + @Stub = Stub + @Timer = Timer + @UpdateFeed = UpdateFeed @Updater = Updater @UnitTestSuite = UnitTestSuite - @FileOps = FileOps @SemanticVersioning = SemanticVersioning - rec = DependencyControl{ name: "DependencyControl", version: "0.7.0", @@ -65,5 +85,6 @@ DependencyControl.__class.version = rec LOADED_MODULES[rec.moduleName], package.loaded[rec.moduleName] = DependencyControl, DependencyControl DependencyControl.updater\scheduleUpdate rec rec\requireModules! +rec\register DependencyControl return DependencyControl diff --git a/modules/l0/DependencyControl/Common.moon b/modules/l0/DependencyControl/Common.moon index 4837253..6348d62 100644 --- a/modules/l0/DependencyControl/Common.moon +++ b/modules/l0/DependencyControl/Common.moon @@ -100,6 +100,42 @@ _itemsEqual = (a, b, onlyNumKeys = true, ignoreExtraAItems, requireIdenticalItem return true + +getTableLength = (tbl) -> + n = 0 + n += 1 for _, _ in pairs tbl + return n + +isPureArrayTable = (tbl) -> + typ = type tbl + return false, nil, typ if typ != "table" + len = getTableLength tbl + return #tbl == len, len, typ + +--- Flattens nested array tables into a single array up to the specified depth. Values that are not (or not converted to) pure array tables are included as-is. +-- @param value The value to flatten. +-- @param depth The maximum depth to flatten. +-- @param toArrayTable An optional function to convert non-array values to array tables. +-- @return table flattened # A flattened array table containing the flattened values. +-- @return number flattenedCount # The number of elements in the flattened array. +flatten = (value, depth = 1, toArrayTable) -> + flattened, f = {}, 0 + + recurse = (v, d) -> + isArray, _, typ = isPureArrayTable v + if toArrayTable and not isArray + v, isArray = toArrayTable v, typ + isArray = isPureArrayTable(v) if isArray == nil + if isArray and d > 0 + recurse nestedVal, d - 1 for nestedVal in *v + else + f += 1 + flattened[f] = v + + recurse value, depth + return flattened, f + + --- Shared constants, enums, and terminology used across DependencyControl modules. -- @class DependencyControlCommon class DependencyControlCommon @@ -153,27 +189,18 @@ class DependencyControlCommon return true return false, msgs.validateNamespace.badNamespace\format namespace - automationDir: { - aegisub.decode_path("?user/automation/autoload"), - aegisub.decode_path("?user/automation/include") - } + @getAutomationDir: (scriptType, rootDir = "?user") => + switch scriptType + when @ScriptType.Automation then aegisub.decode_path("#{rootDir}/automation/autoload") + when @ScriptType.Module then aegisub.decode_path("#{rootDir}/automation/modules") + else nil + + @getTestDir = (scriptType, rootDir = "?user") => + switch scriptType + when @ScriptType.Automation then aegisub.decode_path("#{rootDir}/automation/tests/DepUnit/macros") + when @ScriptType.Module then aegisub.decode_path("#{rootDir}/automation/tests/DepUnit/modules") + else nil - @testDir = {aegisub.decode_path("?user/automation/tests/DepUnit/macros"), - aegisub.decode_path("?user/automation/tests/DepUnit/modules")} - - --- Resolves the install path of a packaged file from its owning script's namespace, - -- mirroring the layout the Updater installs into: automation scripts go to the - -- autoload dir, modules to the include dir (under their namespace path), and test - -- files to the matching DepUnit test dir. - -- @param namespace string - -- @param scriptType number a ScriptType value - -- @param fileName string the file's feed name (e.g. ".moon", "/Common.moon") - -- @param[opt="script"] fileType string "script" or "test" - -- @return string path - @getFileDeployPath = (namespace, scriptType, fileName, fileType = "script") => - subDir = scriptType == @ScriptType.Module and (namespace\gsub "%.", "/") or namespace - baseDir = fileType == "test" and @testDir[scriptType] or @automationDir[scriptType] - return "#{baseDir}/#{subDir}#{fileName}" --- Deep equality comparison. Tables compared recursively; other types use ==. -- Circular references are handled. Metatables are included in the comparison. @@ -210,3 +237,11 @@ class DependencyControlCommon -- @param tbl table the table to deep copy -- @return table the deep-copied table @deepCopy = deepCopy + + --- Flattens nested array tables into a single array up to the specified depth. Values that are not (or not converted to) pure array tables are included as-is. + -- @param value The value to flatten. + -- @param depth The maximum depth to flatten. + -- @param toArrayTable An optional function to convert non-array values to array tables. + -- @return table flattened # A flattened array table containing the flattened values. + -- @return number flattenedCount # The number of elements in the flattened array. + @flatten = flatten diff --git a/modules/l0/DependencyControl/FileOps.moon b/modules/l0/DependencyControl/FileOps.moon index 29f70d7..6be02a7 100644 --- a/modules/l0/DependencyControl/FileOps.moon +++ b/modules/l0/DependencyControl/FileOps.moon @@ -39,6 +39,16 @@ class FileOps dirCopyUnsupported: "Copying directories is currently not supported." missingSource: "Couldn't find source file '%s'." openError: "Couldn't open %s file '%s' for reading: \n%s" + }, + exists: { + doesntExist: "No such file or directory: '%s'." + wrongType: "Expected %s to be a %s but found a %s." + } + listDir: { + notADirectory: "Can only list directories but supplied path '%s' points to a %s." + }, + joinPath: { + invalidSegment: "Invalid path segment type: expected a string or pure array table, got '%s'." } move: { inUseTryingRename: "Target file '%s' already exists and appears to be in use. Trying to rename and delete existing file..." @@ -82,10 +92,10 @@ class FileOps } 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)." + badPath: "Could not generate a valid full path from base path '%s' and namespaced sub-path '%s': %s." } validateFullPath: { - badType: "Argument #1 (path) had the wrong type. Expected 'string', got '%s'." + badType: "Argument #%s (%s) had the wrong type. Expected 'string', got '%s'." tooLong: "The specified path exceeded the maximum length limit (%d > %d)." invalidChars: "The specified path contains one or more invalid characters: '%s'." reservedNames: "The specified path contains reserved path or file names: '%s'." @@ -100,17 +110,18 @@ class FileOps "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" }} - pathMatch = { + @pathSep = ffi.os == "Windows" and "\\" or "/" + @pathMatch = { sep: ffi.os == "Windows" and "\\" or "/" + sepAll: ffi.os == "Windows" and "[\\/]" or "/" 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 + @pathMaxLength = ffi.os == "Windows" and 260 or 4096 createConfig = (noLoad, configDir) -> FileOps.configDir = configDir if configDir @@ -206,7 +217,7 @@ class FileOps -- @param target string -- @return boolean success -- @return string|nil err - copy: ( source, target ) -> + copy: ( source, target, clobber ) -> -- source check mode, sourceFullPath, _, _, fileName = FileOps.attributes source, "mode" switch mode @@ -222,7 +233,7 @@ class FileOps mode, targetFullPath = FileOps.attributes target, "mode" switch mode when "file" - return false, msgs.writeFile.targetExists\format target + return false, msgs.writeFile.targetExists\format target unless clobber when nil return false, msgs.copy.genericError\format source, target, targetFullPath when "directory" @@ -251,17 +262,51 @@ class FileOps else return false, msgs.copy.genericError\format sourceFullPath, targetFullPath, msg - --- Joins multiple path segments into a single path string. + listDir: (dirPath) -> + mode, fullPath = FileOps.attributes dirPath, "mode" + return nil, msgs.listDir.notADirectory\format fullPath, mode if mode != "directory" + return [entry for entry in lfs.dir(fullPath) when entry != "." and entry != ".."] + + --- Joins and resolves 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 + args = {...} + -- detect root from the first string before splitting consumes separators + firstStr = type(args[1]) == "table" and args[1][1] or args[1] + return nil, msgs.joinPath.invalidSegment\format type firstStr if type(firstStr) ~= "string" + absolutePathRoot = type(firstStr) == "string" and FileOps.getPathRoot firstStr + + invalidPathSegmentType = nil + flatPathSegments = Common.flatten args, 3, (value, typ) -> + if typ != "string" + invalidPathSegmentType = typ + return nil + + firstSegment, moreSegments = nil, nil + for segment in FileOps.pathSegments value + if firstSegment + moreSegments or= {firstSegment} + table.insert moreSegments, segment + else firstSegment = segment + return moreSegments or firstSegment, moreSegments + return nil, msgs.joinPath.invalidSegment\format invalidPathSegmentType if invalidPathSegmentType + + -- filter extraneous '.', resolve '..', and clamp path traversal at root + segments = {} + for i, segment in ipairs flatPathSegments + switch segment + when "." then segments[#segments + 1] = segment if i == 1 and not absolutePathRoot + when ".." + if #segments > (absolutePathRoot and 1 or 0) and segments[#segments] != ".." + segments[#segments] = nil + elseif not absolutePathRoot + segments[#segments + 1] = segment + else segments[#segments + 1] = segment + -- re-add root separator for absolute paths on POSIX systems removed by splitting + return "#{absolutePathRoot and ffi.os != "Windows" and FileOps.pathSep or ""}#{table.concat segments, 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 "[^/\\]+" @@ -393,7 +438,7 @@ class FileOps if recurse -- recursively remove contained files and directories - toRemove = [FileOps.joinPath(path, file) for file in lfs.dir path] + toRemove = [FileOps.joinPath(path, file) for file in *FileOps.listDir path] res, details = FileOps.remove toRemove, true unless res fileList = table.concat ["#{path}: #{res[2]}" for path, res in pairs details when not res[1]], "\n" @@ -458,12 +503,9 @@ class FileOps -- @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 + fullPath, dev, dir, file = FileOps.validateFullPath path, false, lfs.currentdir! unless fullPath - path = FileOps.joinPath lfs.currentdir!, path - fullPath, dev, dir, file = FileOps.validateFullPath path - unless fullPath - return nil, msgs.attributes.badPath\format dev + return nil, msgs.attributes.badPath\format dev attr, err, errCode = lfs.attributes fullPath, key if attr @@ -476,41 +518,71 @@ class FileOps else return nil, msgs.attributes.genericError\format err + --- Checks whether a file or directory exists and optionally verifies its type. + -- @param path string|string[] Either a path or an array of path segments + -- @param expectedMode string|nil If specified, the type of the file system entry + -- @return boolean exists true if the file or directory exists and matches the expected type, false if it doesn't exist or doesn't match the expected type, or nil if an error occurred while checking the file + -- @return string|nil err an error message if the file doesn't exist or is of the wrong type + exists: (path, expectedMode) -> + mode, fullPathOrErrMsg = FileOps.attributes path, "mode" + switch mode + when nil then return nil, fullPathOrErrMsg + when false then return false, msgs.exists.doesntExist\format fullPathOrErrMsg + else + return true if not expectedMode or mode == expectedMode + return false, msgs.exists.wrongType\format fullPathOrErrMsg, expectedMode, mode + + + getPathRoot: (absolutePath) -> + return absolutePath\match "^[A-Za-z]:[/\\]" if ffi.os == "Windows" + return absolutePath\match "^/[^/\\]+" + --- Validates and normalizes an absolute filesystem path. -- @param path string|string[] Either a path or an array of path segments -- @param[opt] checkFileExt boolean + -- @param[opt] basePath string|string[] Optional base path to resolve relative paths against. If not provided, relative paths will be rejected. -- @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) == "table" - path = FileOps.joinPath path - elseif type(path) != "string" - return nil, msgs.validateFullPath.badType\format type(path) + validateFullPath: (path, checkFileExt, basePath) -> + if "table" == type path + path, errMsg = FileOps.joinPath path + return nil, errMsg if not path + elseif "string" != type path + return nil, msgs.validateFullPath.badType\format 1, "path", type(path) + + if "table" == type basePath + basePath, errMsg = FileOps.joinPath basePath + return nil, errMsg if not basePath + elseif basePath and "string" != type basePath + return nil, msgs.validateFullPath.badType\format 3, "basePath", type(basePath) + -- expand aegisub path specifiers path = aegisub.decode_path path -- expand home directory on linux homeDir = os.getenv "HOME" path = path\gsub "^~", "#{homeDir}/" if homeDir -- use single native path separators - path = path\gsub "[\\/]+", pathMatch.sep + path = path\gsub "[\\/]+", FileOps.pathSep -- check length - if #path > pathMatch.maxLen - return false, msgs.validateFullPath.tooLong\format #path, pathMatch.maxLen + if #path > FileOps.pathMaxLength + return nil, msgs.validateFullPath.tooLong\format #path, FileOps.pathMaxLength -- check for invalid characters - invChar = path\match pathMatch.invalidChars, ffi.os == "Windows" and 3 or nil + invChar = path\match FileOps.pathMatch.invalidChars, ffi.os == "Windows" and 3 or nil if invChar - return false, msgs.validateFullPath.invalidChars\format invChar - -- check for path escalation - if path\match "%.%." - return false, msgs.validateFullPath.parentPath - - -- parse path structure - dev = if ffi.os == "Windows" then path\match "^[A-Za-z]:" else path\match "^/[^/\\]+" + return nil, msgs.validateFullPath.invalidChars\format invChar + -- check if path is absolute + dev = FileOps.getPathRoot path unless dev - return false, msgs.validateFullPath.notFullPath + -- make relative paths absolute if base path is provided + if basePath + path, errMsg = FileOps.joinPath basePath, path + return nil, errMsg if not path + dev = FileOps.getPathRoot path + else return false, msgs.validateFullPath.notFullPath + -- parse path structure rest = path\sub #dev + 1 dir, file = rest\match "^(.*)[/\\]([^/\\]*)$" unless dir @@ -519,14 +591,14 @@ class FileOps if ffi.os == "Windows" segmentWithoutExt = segment\match("^[^%.]+") or segment if windowsReservedNameSet[segmentWithoutExt\upper!] - return false, msgs.validateFullPath.reservedNames\format segmentWithoutExt + return nil, msgs.validateFullPath.reservedNames\format segmentWithoutExt unless segment\match "[^%.%s]$" - return false, msgs.validateFullPath.notFullPath + return nil, 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 FileOps.pathSep, file} return path, dev, dir, file --- Converts a base path and namespace into a namespaced filesystem path. @@ -544,9 +616,8 @@ class FileOps fullBasePath, msg = FileOps.validateFullPath basePath return nil, msgs.getNamespacedPath.badBasePath\format basePath, msg unless fullBasePath - namespacePath = nested and namespace\gsub("%.", FileOps.pathSep) or namespace - fullPath = FileOps.joinPath fullBasePath, "#{namespacePath}#{ext}" - normalizedFullPath, msg = FileOps.validateFullPath fullPath - return nil, msgs.getNamespacedPath.badPath\format fullPath, msg unless normalizedFullPath + namespacePath = "#{nested and namespace\gsub("%.", FileOps.pathSep) or namespace}#{ext}" + normalizedFullPath, msg = FileOps.validateFullPath namespacePath, false, fullBasePath + return nil, msgs.getNamespacedPath.badPath\format fullBasePath, namespacePath, msg unless normalizedFullPath return normalizedFullPath diff --git a/modules/l0/DependencyControl/GitRepository.moon b/modules/l0/DependencyControl/GitRepository.moon new file mode 100644 index 0000000..4963b31 --- /dev/null +++ b/modules/l0/DependencyControl/GitRepository.moon @@ -0,0 +1,28 @@ +--- Interface to a local git repository for running git commands. +-- @class GitRepository +class GitRepository + --- @param dir string absolute path to the repository root + new: (@dir) => + + --- Runs a git command and returns trimmed stdout+stderr, or nil on failure or empty output. + -- @param args string command and flags passed verbatim after `git -C ` + -- @return string|nil + run: (args) => + h = io.popen ('git -C "%s" %s 2>&1')\format @dir, args + return nil unless h + out = (h\read("*a") or "")\gsub "%s+$", "" + h\close! and out != "" and out or nil + + + getBranch: (ref = "HEAD") => @run "rev-parse --abbrev-ref #{ref}" + getCommitHash: (ref = "HEAD") => @run "rev-parse --short=7 #{ref}" + isAtTag: (ref = "HEAD") => not not @run "describe --exact-match --tags #{ref}" + + --- Returns a git describe-style version suffix for the current HEAD. + -- Returns "" when HEAD is exactly on a tag, "--g" otherwise. + -- @return string + getVersionSuffix: => + return "" if @isAtTag! + branch = @getBranch! or "unknown" + hash = @getCommitHash! or "0000000" + "-#{branch}-g#{hash}" diff --git a/modules/l0/DependencyControl/Record.moon b/modules/l0/DependencyControl/Record.moon index 62986e7..f25d6d5 100644 --- a/modules/l0/DependencyControl/Record.moon +++ b/modules/l0/DependencyControl/Record.moon @@ -9,6 +9,7 @@ Updater = require "l0.DependencyControl.Updater" ModuleLoader = require "l0.DependencyControl.ModuleLoader" ModuleProvider = require "l0.DependencyControl.ModuleProvider" SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" --- DependencyControl record representing one managed or unmanaged script/module. -- @class Record @@ -112,8 +113,8 @@ class Record extends Common msgs.new.badRecord.badNamespace\format @namespace @configFile = configFile or "#{@namespace}.json" - @automationDir = @@automationDir[@scriptType] - @testDir = @@testDir[@scriptType] + @automationDir = Common\getAutomationDir @scriptType + @testDir = Common\getTestDir @scriptType @version, err = SemanticVersioning\toNumber version assert @version, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err @@ -259,22 +260,33 @@ class Record extends Common LOADED_MODULES[@namespace] = nil @@logger\error err return unpack [mdl._ref for mdl in *modules] - + --- Registers DepUnit tests for this record if test modules are available. -- @param[opt] ... any registerTests: (...) => - -- load external tests - haveTests, tests = pcall require, "DepUnit.#{@@ScriptType.name.legacy[@scriptType]}.#{@namespace}" - - if haveTests and not @testsLoaded - @tests, tests.name = tests, @name - modules = table.pack @requireModules! - if @moduleName - @tests\import @ref, modules, ... - else @tests\import modules, ... + return if @haveTestSuite == false or @testSuiteInitialized + + testSuiteIdentifier = UnitTestSuite\getTestSuiteRequireIdentifier @scriptType, @namespace + @haveTestSuite, testsOrErrorMsg = pcall UnitTestSuite\require, testSuiteIdentifier + if not @haveTestSuite + @testSuiteLoadError = testsOrErrorMsg + return + + @tests = testsOrErrorMsg + @tests.name = @name + + modules = table.pack @requireModules! + success, errMsg = nil, nil + if @moduleName + success, errMsg = pcall @tests\import, @ref, modules, ... + else + success, errMsg = pcall @tests\import, modules, ... - @tests\registerMacros! - @testsLoaded = true + if success + @testSuiteInitialized = true + else + @testSuiteInitializeError = errMsg + @@logger\warn "Error initializing test suite for #{@@terms.scriptType.singular[@scriptType]} '#{@name}': #{errMsg}" --- Finalizes module registration and swaps dummy module refs for real refs. -- @param selfRef table @@ -294,6 +306,7 @@ class Record extends Common -- @param[opt] isActive function -- @param[opt] submenu string|boolean registerMacro: (name=@name, description=@description, process, validate, isActive, submenu) => + @registerTests! -- alternative signature takes name and description from script if type(name)=="function" process, validate, isActive, submenu = name, description, process, validate @@ -318,6 +331,7 @@ class Record extends Common -- @param[opt] macros table[] -- @param[opt=true] submenuDefault boolean registerMacros: (macros = {}, submenuDefault = true) => + @registerTests! for macro in *macros -- allow macro table to omit name and description submenuIdx = type(macro[1])=="function" and 4 or 6 diff --git a/modules/l0/DependencyControl/ScriptTargetFilter.moon b/modules/l0/DependencyControl/ScriptTargetFilter.moon new file mode 100644 index 0000000..b33e889 --- /dev/null +++ b/modules/l0/DependencyControl/ScriptTargetFilter.moon @@ -0,0 +1,71 @@ +Common = require "l0.DependencyControl.Common" + +-- all concrete script types (the ScriptType table also holds a `name` lookup sub-table) +scriptTypeList = [v for k, v in pairs Common.ScriptType when k != "name"] +table.sort scriptTypeList + +--- Selects which packages a feed operation should process, by script type and namespace. +-- Construct it from a spec table, or empty and build it up fluently — every builder method +-- returns self so calls can be chained. Because modules and automation scripts aren't required +-- to have unique namespaces, rules are keyed by script type first. +-- +-- @usage ScriptTargetFilter!\include(Common.ScriptType.Module, "l0.DependencyControl") +-- @usage ScriptTargetFilter!\includeAll Common.ScriptType.Module -- every module +-- @usage ScriptTargetFilter!\includeAll! -- everything +-- @usage ScriptTargetFilter {[Common.ScriptType.Module]: {include: {"l0.DependencyControl"}}} +-- @class ScriptTargetFilter +class ScriptTargetFilter + @scriptTypeList = scriptTypeList + + --- @param[opt] spec table {[scriptType] = true | {include: {ns, ...}, exclude: {ns, ...}}} + new: (spec) => + @rules = {} -- [scriptType] = {all: bool, include: {ns -> true}, exclude: {ns -> true}} + if spec + for scriptType, rule in pairs spec + if rule == true + @includeAll scriptType + else + @include scriptType, ns for ns in *(rule.include or {}) + @exclude scriptType, ns for ns in *(rule.exclude or {}) + + --- Lazily creates and returns the rule table for a script type. + -- @local + ruleFor: (scriptType) => + @rules[scriptType] or= {include: {}, exclude: {}} + @rules[scriptType] + + --- Includes a single namespace of the given script type. + -- @return ScriptTargetFilter self + include: (scriptType, namespace) => + @ruleFor(scriptType).include[namespace] = true + @ + + --- Includes every namespace of the given script type, or — when called without an + --- argument — every namespace of every script type. + -- @return ScriptTargetFilter self + includeAll: (scriptType) => + if scriptType + @ruleFor(scriptType).all = true + else + @includeAll t for t in *@@scriptTypeList + @ + + --- Excludes a single namespace of the given script type (takes precedence over includes). + -- @return ScriptTargetFilter self + exclude: (scriptType, namespace) => + @ruleFor(scriptType).exclude[namespace] = true + @ + + --- Returns the script types this filter would process (those carrying any rule), sorted. + -- @return {scriptType, ...} + scriptTypes: => + [t for t in *@@scriptTypeList when @rules[t]] + + --- Tests whether a script of the given type and namespace should be processed. + -- @return boolean + matches: (scriptType, namespace) => + rule = @rules[scriptType] + return false unless rule + return false if rule.exclude[namespace] + return true if rule.all + rule.include[namespace] or false diff --git a/modules/l0/DependencyControl/UnitTestSuite.moon b/modules/l0/DependencyControl/UnitTestSuite.moon index 575f426..fbdb579 100644 --- a/modules/l0/DependencyControl/UnitTestSuite.moon +++ b/modules/l0/DependencyControl/UnitTestSuite.moon @@ -1,10 +1,11 @@ Logger = require "l0.DependencyControl.Logger" --- 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" +DependencyControl = nil + +-- make sure tests can be loaded from the test directory +package.path ..= aegisub.decode_path("?user/automation/tests") .. "/?.lua;" --- A class for all single unit tests. -- Provides useful assertion and logging methods for a user-specified test function. @@ -678,6 +679,19 @@ class UnitTestClass return false, failed +--- A bundle of helper utilities handed to a suite's import function as its trailing argument. +-- @class UnitTestSuiteControls +class UnitTestSuiteControls + -- @param suite UnitTestSuite the suite to expose controls for + new: (suite) => + @_suite = suite -- we don't want to encourage direct access to the suite, but will leave the option for the brave or desperate + + --- Requires one of the suite's sibling test modules by its leaf name. + -- Resolved against the test suite identifier, so the same call works for both the Aegisub-default and custom test locations (e.g. as used in CI environments). + -- @tparam string leaf the module name relative to the test root (e.g. "FileOps") + -- @return the loaded test module + requireTest: (leaf) => @_suite\requireTestLeaf leaf + --- 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 @@ -703,8 +717,39 @@ class UnitTestSuite @UnitTest = UnitTest @UnitTestClass = UnitTestClass + @UnitTestSuiteControls = UnitTestSuiteControls @Stub = Stub + --- Return the require specifier used to load DepCtrl test suites in Aegisub environments. + -- In an Aegisub environment, test suites reside in '?user/automation/tests/DepUnit/(modules|macros)/.(moon|lua)'. + -- @param scriptType Common.ScriptType value indicating whether the test suite is for a module or an automation script. + -- @param namespace the namespaced identifier of the package under test (e.g. 'l0.Functional'). + -- @return the require specifier used to load the test suite. + @getDefaultTestSuiteRequireIdentifier = (scriptType, namespace) => + "DepUnit.#{Common.ScriptType.name.legacy[scriptType]}.#{namespace}" + + -- Returns the require specifier used to load DepCtrl test suites in the current environment. + -- Accepts a hook via the global variable DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER to be used + -- by CLI/CI test runners loading the test suites from the source repo or other locations. + -- @param scriptType Common.ScriptType value indicating whether the test suite is for a module or an automation script. + -- @param namespace the namespaced identifier of the package under test (e.g. 'l0.Functional'). + @getTestSuiteRequireIdentifier = (scriptType, namespace) => + DependencyControl or= require "l0.DependencyControl" + + switch type(DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER) + when "nil" then self.getDefaultTestSuiteRequireIdentifier(scriptType, namespace) + when "string" then DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER + when "function" then DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER(scriptType, namespace, DependencyControl) + else error "DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER must be either a string or a function, got a #{type DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER}" + + --- Requires a test module or the entire test suite. + -- @param requireIdentifier string the require specifier of the test suite to be loaded. Use @getTestSuiteRequireIdentifier to obtain it for Aegisub environments. + -- @return the loaded test suite module + @require: (suiteIdentifier) => + test = require suiteIdentifier + test.suiteRequireIdentifier or= suiteIdentifier + return test + --- Creates a complete unit test suite for a module or automation script. -- Using this constructor will create all test classes and tests automatically. -- @tparam string namespace the namespace of the module or automation script to test. @@ -737,12 +782,13 @@ class UnitTestSuite @order or= {} @order[#@order+1] = clsName for clsName in *classes._order - --- Imports test classes from a function (passing in the specified arguments) and adds them to the suite. - -- Use this if you need to add additional test classes to an existing @{UnitTestSuite} object. - -- @tparam [opt] args a hashtable of @{UnitTestClass} constructor tables by name. + --- Loads test classes from a function and adds them to the suite, passing in the specified arguments and a suite controller. + -- Generally used for dependency injection (e.g. the DepCtrl runners pass in the module under test as well as its declared dependencies). + -- @param ... any dependencies or other arguments to be passed to the test suite's import function import: (...) => return false unless @importFunc - classes = self.importFunc ... + controls = UnitTestSuiteControls @ + classes = (@importFunc) ..., controls @logger\assert type(classes) == "table", msgs.import.noTableReturned, type classes @addClasses classes @importFunc = nil @@ -751,11 +797,21 @@ class UnitTestSuite -- If the test script is placed in the appropriate directory (according to module/automation script namespace), -- this is automatically handled by DependencyControl. registerMacros: => + return if @macrosRegistered + menuItem = {"DependencyControl", "Run Tests", @name or @namespace, "[All]"} aegisub.register_macro table.concat(menuItem, "/"), msgs.registerMacros.allDesc, -> @run! for cls in *@classes menuItem[4] = cls.name aegisub.register_macro table.concat(menuItem, "/"), cls.description, -> cls\run! + @macrosRegistered = true + + --- Requires a specific test leaf module. + -- Used by multi-file test suites to load their sibling test modules without hard-coding environment-specific paths. + -- @tparam string leafIdentifier the module name relative to the test root (e.g. "FileOps") + requireTestLeaf: (leafIdentifier) => + @logger\assert @suiteRequireIdentifier, "test suite must have a suite require identifier configured in order to resolve sibling test '#{leafIdentifier}'" + require "#{@suiteRequireIdentifier}.#{leafIdentifier}" --- Runs all test classes of this suite in the specified order. -- @param[opt=false] abortOnFail stops testing once a test fails diff --git a/modules/l0/DependencyControl/UpdateFeed.moon b/modules/l0/DependencyControl/UpdateFeed.moon index 68597eb..fd6037a 100644 --- a/modules/l0/DependencyControl/UpdateFeed.moon +++ b/modules/l0/DependencyControl/UpdateFeed.moon @@ -2,16 +2,36 @@ json = require "json" 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" defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" +ScriptTargetFilter = require "l0.DependencyControl.ScriptTargetFilter" + +-- Iterates the real packages of a loaded feed that pass the given filter, yielding +-- (pkgProxy, scriptType, section). pkgProxy exposes the package's `namespace` alongside its +-- raw fields. Rolling-template keys the expander writes into a section container (e.g. +-- fileBaseUrl/localFileBasePath) are skipped: real packages are tables carrying `channels`. +walkPackages = (feed, filter) -> + coroutine.wrap -> + for scriptType in *filter\scriptTypes! + section = Common.ScriptType.name.legacy[scriptType] + packages = feed.data[section] + continue unless packages + + for namespace, pkg in pairs packages + continue unless type(pkg) == "table" and pkg.channels + continue unless filter\matches scriptType, namespace + pkgProxy = setmetatable {}, __index: (_, k) -> k == "namespace" and namespace or pkg[k] + coroutine.yield pkgProxy, scriptType, section --- Downloaded and expanded update feed data source. -- @class UpdateFeed class UpdateFeed extends Common templateData = { - maxDepth: 7, + maxDepth: 7 templates: { feedName: {depth: 1, order: 1, key: "name" } baseUrl: {depth: 1, order: 2, key: "baseUrl" } @@ -24,7 +44,16 @@ class UpdateFeed extends Common platform: {depth: 7, order: 1, key: "platform" } fileName: {depth: 7, order: 2, key: "name" } -- rolling templates - fileBaseUrl: {key: "fileBaseUrl", rolling: true } + localFileBasePath: { + key: "localFileBasePath", + rolling: true, + expansionModes: {local: true}, + default: "./" + } + fileBaseUrl: { + key: "fileBaseUrl", + rolling: true + } } sourceAt: {} } @@ -41,6 +70,14 @@ class UpdateFeed extends Common parse: "Error parsing feed." invalidScriptType: "Invalid or unsupported script type: '%s'. Supported types: %s." } + bundle: { + invalidSourcePath: "invalid source path for %s (%s): %s" + invalidDeployPath: "couldn't generate a valid deploy path for %s (channel %s) file '%s' with root dir '%s': %s" + srcNotFound: "source not found: %s" + copyFailed: "error copying %s: %s" + copied: "%s -> %s" + skipped: "skipped (already exists): %s" + } } @defaultConfig = { @@ -48,6 +85,30 @@ class UpdateFeed extends Common } @cache = {} + --- Variable-expansion modes for @{expand}. + -- Remote (default): expand `fileBaseUrl`/`url` to their download URLs. + -- Local: additionally resolve the `localFileBasePath`/`localFilePath` sister fields to + -- on-disk paths (used by tooling such as the bundler). The remote fields are left intact. + @ExpansionMode = Enum "UpdateFeedExpansionMode", { + Remote: "remote" + Local: "local" + } + + --- Resolves the install path of a packaged file from its owning script's namespace, + -- mirroring the layout the Updater installs into: automation scripts go to the + -- autoload dir, modules to the include dir (under their namespace path), and test + -- files to the matching DepUnit test dir. + -- @param namespace string + -- @param scriptType number a ScriptType value + -- @param fileName string the file's feed name (e.g. ".moon", "/Common.moon") + -- @param[opt="script"] fileType string "script" or "test" + -- @param[opt] rootDir string the root directory for deployment + -- @return string path + @getFileDeployPath = (namespace, scriptType, fileName, fileType = "script", rootDir) => + subDir = scriptType == Common.ScriptType.Module and (namespace\gsub "%.", "/") or namespace + baseDir = fileType == "test" and Common\getTestDir(scriptType, rootDir) or Common\getAutomationDir scriptType, rootDir + return FileOps.validateFullPath "#{subDir}#{fileName}", false, baseDir + fileBaseName = "l0.#{@@__name}_" fileMatchTemplate = "l0.#{@@__name}_%x%x%x%x.*%.json" feedsHaveBeenTrimmed = false @@ -71,6 +132,14 @@ class UpdateFeed extends Common -- @param[opt] config table -- @param[opt] logger Logger new: (@url, autoFetch = true, fileName, @config = {}, @logger = defaultLogger) => + meta = getmetatable @ + setmetatable @, { + __index: (self, key) -> + rawValue = meta[key] + return rawValue if rawValue != nil + if key == 'url' then return self.fileName and "file://#{self.fileName}" or nil + } + -- fill in missing config values @config[k] = v for k, v in pairs @@defaultConfig when @config[k] == nil @fileName = fileName @@ -114,9 +183,11 @@ class UpdateFeed extends Common --- Loads and parses a local feed JSON file, expanding all template variables in-place. -- Use this to load a feed already on disk without going through the network. ---@param path string Local filesystem path to the feed JSON file. + ---@param[opt] mode UpdateFeedExpansionMode expansion mode (Remote by default; Local also + -- resolves the localFileBasePath/localFilePath fields against the feed's directory). ---@return table|boolean ---@return string|nil err - loadFile: (path) => + loadFile: (path, mode = @@ExpansionMode.Remote) => handle, err = io.open path unless handle return false, msgs.errors.cantOpen\format err @@ -131,14 +202,17 @@ class UpdateFeed extends Common @@ScriptType.name.legacy[@@ScriptType.Module], "knownFeeds"} when not data[key] @data, @@cache[@url] = data, data - @expand! + @feedDir = path\match("^(.*)[/\\][^/\\]*$") or "." + + @expand mode return @data --- Walks the parsed feed JSON and expands @{template} variables in-place. - -- Called automatically by @{fetch}; results are cached in @data. + -- @param mode UpdateFeedExpansionMode expansion mode local mode resolves addition rolling templates for local source file paths -- @return table data - expand: => + expand: (mode = @@ExpansionMode.Remote) => {:templates, :maxDepth, :sourceAt, :rolling, :sourceKeys} = templateData + localMode = mode == @@ExpansionMode.Local vars, rvars = {}, {i, {} for i=0, maxDepth} expandTemplates = (val, depth, rOff=0) -> @@ -168,9 +242,11 @@ class UpdateFeed extends Common -- update rolling template variables last for name,_ in pairs rolling - rvars[depth][name] = obj[templates[name].key] or rvars[depth-1][name] or "" + continue if templates[name].expansionModes and not templates[name].expansionModes[mode] + default = templates[name].default + rvars[depth][name] = obj[templates[name].key] or rvars[depth-1][name] or default rvars[depth][name] = expandTemplates rvars[depth][name], depth, -1 - obj[templates[name].key] and= rvars[depth][name] + obj[templates[name].key] = rvars[depth][name] -- expand variables in non-template strings and recurse tables for k,v in pairs obj @@ -231,3 +307,99 @@ class UpdateFeed extends Common -- @return string|nil err getModule: (namespace, config, autoChannel) => @getScript namespace, @@ScriptType.Module, config, autoChannel + + --- Returns the default channel's version for a module namespace, or nil. + -- "Default" means the channel with default:true; falls back to the first channel found. + -- @param namespace string + -- @return string|nil version + getModuleVersion: (namespace) => + pkg = @data.modules and @data.modules[namespace] + return nil unless pkg + fallback = nil + for _, ch in pairs pkg.channels or {} + fallback or= ch.version + return ch.version if ch.default + fallback + + --- Copies every file listed in the feed to distDir using the Updater's install layout. + -- The feed must have been loaded with ExpansionMode.Local so localFileBasePath is populated. + -- @param distDir string absolute path of the output dist directory + -- @param scriptTypes? table list of script types to deploy (by default goes over automation scripts and modules) + -- @param clobber? boolean overwrite existing destination files (defaults to false) + -- @return number fileCount number of files successfully copied + -- @return number errCount number of files that failed to copy (e.g. due to missing source file or copy error) + -- @param filter? ScriptTargetFilter restricts which packages are deployed (by default deploys all) + deployFiles: (distDir, filter, clobber = false) => + fileCount, errCount = 0, 0 + + for file, channel, pkg, _, scriptType in @walkFiles filter + unless file.localFilePath + @logger\warn msgs.bundle.invalidSourcePath, pkg.namespace, channel.name, tostring file.name + errCount += 1 + continue + + fileExists, errMsg = FileOps.exists file.localFilePath, "file" + unless fileExists + @logger\warn errMsg + errCount += 1 + continue + + dstPath, errMsg = @@getFileDeployPath pkg.namespace, scriptType, file.name, file.type or "script", distDir + unless dstPath + @logger\warn msgs.bundle.invalidDeployPath, pkg.namespace, channel.name, file.name, distDir, tostring errMsg + errCount += 1 + continue + + unless clobber + if FileOps.exists dstPath, "file" + @logger\hint msgs.bundle.skipped, dstPath + continue + + FileOps.mkdir dstPath, true, true + copied, copyErr = FileOps.copy file.localFilePath, dstPath + if copied + @logger\hint msgs.bundle.copied, file.localFilePath, dstPath + fileCount += 1 + else + @logger\warn msgs.bundle.copyFailed, file.localFilePath, tostring copyErr + errCount += 1 + + return fileCount, errCount + + --- Returns a coroutine-based iterator over the packages of this feed that pass the filter. + -- The feed must have been loaded before calling this method. + -- Each iteration yields three values: + -- pkg – the package object; the package key is accessible via `.namespace` + -- scriptType – the script type (Common.ScriptType.Module / .Automation) + -- section – the section name (e.g. "macros" or "modules") + -- @param filter? ScriptTargetFilter restricts which packages are walked (default: all) + -- @return function iterator + walkPackages: (filter = ScriptTargetFilter!\includeAll!) => + walkPackages @, filter + + --- Returns a coroutine-based iterator over every file entry of the packages passing the filter. + -- The feed must have been loaded before calling this method. + -- Each iteration yields five values: + -- file – the file object; `.localFilePath` resolves localFileBasePath+name against @feedDir + -- channel – the channel object; the channel key is accessible via `.name` + -- pkg – the package object; the package key is accessible via `.namespace` + -- section – the section name (e.g. "macros" or "modules") + -- scriptType – the script type (Common.ScriptType.Module / .Automation) + -- @param filter? ScriptTargetFilter restricts which packages are walked (default: all) + -- @return function iterator + walkFiles: (filter = ScriptTargetFilter!\includeAll!) => + FileOps = require "l0.DependencyControl.FileOps" + feedDir = @feedDir + + coroutine.wrap -> + for pkg, scriptType, section in walkPackages @, filter + for channelName, channel in pairs pkg.channels or {} + chanProxy = setmetatable {}, __index: (_, k) -> k == "name" and channelName or channel[k] + + for file in *channel.files or {} + fileProxy = setmetatable {}, { + __index: (_, k) -> + return (FileOps.validateFullPath file.localFileBasePath .. file.name, false, feedDir) if k == "localFilePath" + file[k] + } + coroutine.yield fileProxy, chanProxy, pkg, section, scriptType diff --git a/modules/l0/DependencyControl/Updater.moon b/modules/l0/DependencyControl/Updater.moon index f07ac81..38ff2dc 100644 --- a/modules/l0/DependencyControl/Updater.moon +++ b/modules/l0/DependencyControl/Updater.moon @@ -27,6 +27,7 @@ class UpdaterBase extends Common [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" + [33]: "Aborted %s of %s '%s' because it attempted to deploy a file (%s) outside of its namespaced path." [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." @@ -335,7 +336,9 @@ class UpdateTask extends UpdaterBase tmpName, prettyName = "#{tmpDir}/#{file.type}/#{baseName}", baseName switch file.type when "script", "test" - file.fullName = Common\getFileDeployPath @record.namespace, @record.scriptType, file.name, file.type + return finish -33, file.name if file.name\match "%.%." + file.fullName = UpdateFeed\getFileDeployPath @record.namespace, @record.scriptType, file.name, file.type + prettyName ..= " (Unit Test)" if file.type == "test" else file.unknown = true diff --git a/modules/l0/DependencyControl/Tests.moon b/modules/l0/DependencyControl/test.moon similarity index 98% rename from modules/l0/DependencyControl/Tests.moon rename to modules/l0/DependencyControl/test.moon index dd68eb3..e6cddb7 100644 --- a/modules/l0/DependencyControl/Tests.moon +++ b/modules/l0/DependencyControl/test.moon @@ -1,6 +1,10 @@ DependencyControl = require "l0.DependencyControl" -DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> +DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> + -- The suite controls object is appended by UnitTestSuite\import as the final argument. + -- Its index varies by loader (CLI vs Aegisub pass different arg counts), so grab the last one. + nArgs = select "#", ... + controls = select nArgs, ... lfs = require "lfs" ffi = require "ffi" Logger = require "l0.DependencyControl.Logger" @@ -15,11 +19,13 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> Record = require "l0.DependencyControl.Record" UpdateFeed = require "l0.DependencyControl.UpdateFeed" ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" + GitRepository = require "l0.DependencyControl.GitRepository" Timer = require "l0.DependencyControl.Timer" TerribleMutex = require "l0.DependencyControl.TerribleMutex" Downloader = require "l0.DependencyControl.Downloader" Crypto = require "l0.DependencyControl.Crypto" ModuleProvider = require "l0.DependencyControl.ModuleProvider" + Stub = require "l0.DependencyControl.Stub" BADMUTEX_MODULE_NAME = "BM.BadMutex" TIMER_MODULE_NAME = "l0.DependencyControl.Timer" @@ -442,6 +448,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> } } + CommonExtra: (controls\requireTest "Common") basePath + FileOps: { _description: "Tests for FileOps path validation and filesystem utilities." @@ -453,31 +461,32 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> ut\assertString err validateFullPath_parentDir: (ut) -> + -- ".." is now resolved rather than rejected result = FileOps.validateFullPath {basePath, "..", "escape.txt"} - ut\assertFalse result + ut\assertString result -- resolves to parent dir + escape.txt validateFullPath_tooLong: (ut) -> result = FileOps.validateFullPath {basePath, "#{string.rep 'a', 300}.txt"} - ut\assertFalse result + ut\assertNil result validateFullPath_invalidChars: (ut) -> return unless isWindows result = FileOps.validateFullPath {basePath, "with.txt"} - ut\assertFalse result + ut\assertNil result validateFullPath_reservedNames: (ut) -> return unless isWindows result = FileOps.validateFullPath {basePath, "CON", "file.txt"} - ut\assertFalse result + ut\assertNil result validateFullPath_reservedNameWithExt: (ut) -> return unless isWindows result = FileOps.validateFullPath {basePath, "NUL.txt"} - ut\assertFalse result + ut\assertNil result validateFullPath_trailingDotSegment: (ut) -> result = FileOps.validateFullPath {basePath, "trailingdot.", "file.txt"} - ut\assertFalse result + ut\assertNil result validateFullPath_valid: (ut) -> path, dev, dir, file = FileOps.validateFullPath {basePath, "file.txt"} @@ -711,6 +720,9 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> } } + FileOpsExtra: (controls\requireTest "FileOps") basePath, isWindows + + Logger: { _description: "Tests for the Logger class covering message formatting, dump serialization, and log dispatch." @@ -1950,15 +1962,18 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> registered = {} ut\stub(aegisub, "register_macro")\calls (...) -> registered[#registered+1] = table.pack ... updaterMock = {scheduleUpdate: (->), releaseLock: ->} + registerTestsStub = Stub! rec = { name: "TestScript", description: "desc", config: {c: {customMenu: "Automation"}}, + registerTests: registerTestsStub, __class: {updater: updaterMock} } Record.registerMacro rec, "MyMacro", "My macro", (->) ut\assertEquals #registered, 1 ut\assertContains registered[1][1], "MyMacro" + registerTestsStub\assertCalledOnceWith rec _order: { "checkVersion_equal", "checkVersion_greater", "checkVersion_older", "checkVersion_recordArg", @@ -2131,6 +2146,12 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- Real-HTTP exercise of the Downloader backends against a local pegasus/copas server -- (test/helpers/mock-http-server). Self-gating via _condition: skipped unless the server's -- Lua deps are installed, so the default offline run never needs luasocket/copas/pegasus. + UpdateFeedExtra: (controls\requireTest "UpdateFeed") basePath, DepCtrl + + GitRepository: (controls\requireTest "GitRepository")! + + ScriptTargetFilter: (controls\requireTest "ScriptTargetFilter")! + DownloaderIntegration: { _description: "Real-HTTP Downloader tests against a local test server (runs when launchable)." diff --git a/modules/l0/DependencyControl/test/Common.moon b/modules/l0/DependencyControl/test/Common.moon new file mode 100644 index 0000000..099a463 --- /dev/null +++ b/modules/l0/DependencyControl/test/Common.moon @@ -0,0 +1,99 @@ +-- Additional Common tests: getAutomationDir, getTestDir, flatten. +-- Called from Tests.moon as: (require "...test.Common") basePath +(basePath) -> + ffi = require "ffi" + Common = require "l0.DependencyControl.Common" + + { + _description: "Tests for Common utilities: getAutomationDir, getTestDir, flatten." + + -- getAutomationDir + + getAutomationDir_automation: (ut) -> + result = Common\getAutomationDir Common.ScriptType.Automation + ut\assertString result + ut\assertContains result, "autoload" + + getAutomationDir_module: (ut) -> + result = Common\getAutomationDir Common.ScriptType.Module + ut\assertString result + ut\assertContains result, "modules" + + getAutomationDir_customRoot: (ut) -> + (ut\stub aegisub, "decode_path")\calls (path) -> path + result = Common\getAutomationDir Common.ScriptType.Automation, "myroot" + ut\assertString result + ut\assertContains result, "myroot" + ut\assertContains result, "autoload" + + getAutomationDir_unknown: (ut) -> + result = Common\getAutomationDir 99 + ut\assertNil result + + -- getTestDir + + getTestDir_automation: (ut) -> + result = Common\getTestDir Common.ScriptType.Automation + ut\assertString result + ut\assertContains result, "macros" + + getTestDir_module: (ut) -> + result = Common\getTestDir Common.ScriptType.Module + ut\assertString result + ut\assertContains result, "modules" + + getTestDir_customRoot: (ut) -> + (ut\stub aegisub, "decode_path")\calls (path) -> path + result = Common\getTestDir Common.ScriptType.Module, "myroot" + ut\assertString result + ut\assertContains result, "myroot" + ut\assertContains result, "DepUnit" + + -- flatten + + flatten_depth2Array: (ut) -> + flat, n = Common.flatten {{"a", "b"}, {"c"}}, 2 + ut\assertEquals n, 3 + ut\assertEquals flat[1], "a" + ut\assertEquals flat[2], "b" + ut\assertEquals flat[3], "c" + + flatten_depth1StopsEarly: (ut) -> + flat, n = Common.flatten {{"a", "b"}, "c"}, 1 + ut\assertEquals n, 2 + ut\assertTable flat[1] + ut\assertEquals flat[2], "c" + + flatten_depth0NoFlatten: (ut) -> + flat, n = Common.flatten {{"a"}, "b"}, 0 + ut\assertEquals n, 1 + ut\assertTable flat[1] + + flatten_scalar: (ut) -> + flat, n = Common.flatten "hello" + ut\assertEquals n, 1 + ut\assertEquals flat[1], "hello" + + flatten_returnsCount: (ut) -> + _, n = Common.flatten {"x", "y", "z"}, 2 + ut\assertEquals n, 3 + + flatten_toArrayTable: (ut) -> + input = {42, "x"} + converter = (v, typ) -> + return {"a", "b"} if typ == "number" + v + flat, n = Common.flatten input, 2, converter + ut\assertEquals n, 3 + ut\assertEquals flat[1], "a" + ut\assertEquals flat[2], "b" + ut\assertEquals flat[3], "x" + + _order: { + "getAutomationDir_automation", "getAutomationDir_module", + "getAutomationDir_customRoot", "getAutomationDir_unknown", + "getTestDir_automation", "getTestDir_module", "getTestDir_customRoot", + "flatten_depth2Array", "flatten_depth1StopsEarly", "flatten_depth0NoFlatten", + "flatten_scalar", "flatten_returnsCount", "flatten_toArrayTable" + } + } diff --git a/modules/l0/DependencyControl/test/FileOps.moon b/modules/l0/DependencyControl/test/FileOps.moon new file mode 100644 index 0000000..9d75062 --- /dev/null +++ b/modules/l0/DependencyControl/test/FileOps.moon @@ -0,0 +1,99 @@ +-- Additional FileOps tests: exists, getPathRoot, listDir, joinPath dot-resolution. +-- Called from Tests.moon as: (require "...test.FileOps") basePath, isWindows +(basePath, isWindows) -> + ffi = require "ffi" + lfs = require "lfs" + FileOps = require "l0.DependencyControl.FileOps" + pathSep = isWindows and "\\" or "/" + + { + _description: "Additional FileOps tests: exists, getPathRoot, listDir, joinPath." + + -- validateFullPath with basePath + + validateFullPath_withBasePath: (ut) -> + result = FileOps.validateFullPath "file.txt", false, basePath + ut\assertString result + ut\assertContains result, "file.txt" + + -- getPathRoot + + getPathRoot_windowsPath: (ut) -> + return unless isWindows + result = FileOps.getPathRoot "C:\\Users\\foo" + ut\assertEquals result, "C:\\" + + getPathRoot_posixPath: (ut) -> + return if isWindows + result = FileOps.getPathRoot "/usr/local" + ut\assertEquals result, "/usr" + + getPathRoot_relative: (ut) -> + result = FileOps.getPathRoot "relative/path" + ut\assertNil result + + -- joinPath: dot/dot-dot resolution + + joinPath_resolvesDotDot: (ut) -> + result = FileOps.joinPath "a", "b", "..", "c" + ut\assertEquals result, "a#{pathSep}c" + + joinPath_invalidSegment: (ut) -> + result, err = FileOps.joinPath 42 + ut\assertNil result + ut\assertString err + + -- exists + + exists_fileFound: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + result = FileOps.exists {basePath, "file.txt"}, "file" + ut\assertTrue result + + exists_notFound: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> nil, "No such file or directory", 2 + result, err = FileOps.exists {basePath, "missing.txt"}, "file" + ut\assertFalse result + ut\assertString err + + exists_wrongType: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "directory" + result, err = FileOps.exists {basePath, "dir"}, "file" + ut\assertFalse result + ut\assertString err + + exists_noTypeCheck: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "directory" + result = FileOps.exists {basePath, "dir"} + ut\assertTrue result + + -- listDir + + listDir_success: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "directory" + entries = {"a.txt", ".", "b.lua", ".."} + idx = 0 + makeIter = -> + i = 0 + -> + i += 1 + entries[i] + (ut\stub lfs, "dir")\calls (path) -> makeIter! + result = FileOps.listDir basePath + ut\assertTable result + ut\assertEquals #result, 2 + + listDir_notDirectory: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + result, err = FileOps.listDir basePath + ut\assertNil result + ut\assertString err + + _order: { + "validateFullPath_withBasePath", + "getPathRoot_windowsPath", "getPathRoot_posixPath", "getPathRoot_relative", + "joinPath_resolvesDotDot", "joinPath_invalidSegment", + "exists_fileFound", "exists_notFound", "exists_wrongType", "exists_noTypeCheck", + "listDir_success", "listDir_notDirectory" + } + } diff --git a/modules/l0/DependencyControl/test/GitRepository.moon b/modules/l0/DependencyControl/test/GitRepository.moon new file mode 100644 index 0000000..77b7e87 --- /dev/null +++ b/modules/l0/DependencyControl/test/GitRepository.moon @@ -0,0 +1,84 @@ +-- GitRepository tests: git command execution and version suffix derivation. +-- Called from Tests.moon as: (require "...test.GitRepository")! +() -> + GitRepository = require "l0.DependencyControl.GitRepository" + + { + _description: "Tests for GitRepository: git command execution and version suffix derivation." + + -- run + + run_returnsOutput: (ut) -> + git = GitRepository "/some/dir" + mockHandle = { + read: (h, f) -> "main\n" + close: (h) -> true + } + (ut\stub io, "popen")\returns mockHandle + ut\assertEquals git\run("rev-parse --abbrev-ref HEAD"), "main" + + run_nilOnEmptyOutput: (ut) -> + git = GitRepository "/some/dir" + mockHandle = { + read: (h, f) -> " \n" + close: (h) -> true + } + (ut\stub io, "popen")\returns mockHandle + ut\assertNil git\run "status" + + run_nilOnPopenFailure: (ut) -> + git = GitRepository "/some/dir" + (ut\stub io, "popen")\returns nil + ut\assertNil git\run "status" + + -- getBranch / getCommitHash / isAtTag delegate to run + + getBranch_returnsRef: (ut) -> + git = GitRepository "/some/dir" + (ut\stub git, "run")\returns "feature/x" + ut\assertEquals git\getBranch!, "feature/x" + + getCommitHash_returnsHash: (ut) -> + git = GitRepository "/some/dir" + (ut\stub git, "run")\returns "a1b2c3d" + ut\assertEquals git\getCommitHash!, "a1b2c3d" + + isAtTag_true: (ut) -> + git = GitRepository "/some/dir" + (ut\stub git, "run")\returns "v1.0.0" + ut\assertTrue git\isAtTag! + + isAtTag_false: (ut) -> + git = GitRepository "/some/dir" + (ut\stub git, "run")\returns nil + ut\assertFalse git\isAtTag! + + -- getVersionSuffix + + getVersionSuffix_atTag: (ut) -> + git = GitRepository "/some/dir" + (ut\stub git, "isAtTag")\returns true + ut\assertEquals git\getVersionSuffix!, "" + + getVersionSuffix_notAtTag: (ut) -> + git = GitRepository "/some/dir" + (ut\stub git, "isAtTag")\returns false + (ut\stub git, "getBranch")\returns "main" + (ut\stub git, "getCommitHash")\returns "abc1234" + ut\assertEquals git\getVersionSuffix!, "-main-gabc1234" + + getVersionSuffix_unknownFallbacks: (ut) -> + git = GitRepository "/some/dir" + (ut\stub git, "isAtTag")\returns false + (ut\stub git, "getBranch")\returns nil + (ut\stub git, "getCommitHash")\returns nil + ut\assertEquals git\getVersionSuffix!, "-unknown-g0000000" + + _order: { + "run_returnsOutput", "run_nilOnEmptyOutput", "run_nilOnPopenFailure", + "getBranch_returnsRef", "getCommitHash_returnsHash", + "isAtTag_true", "isAtTag_false", + "getVersionSuffix_atTag", "getVersionSuffix_notAtTag", + "getVersionSuffix_unknownFallbacks" + } + } diff --git a/modules/l0/DependencyControl/test/ScriptTargetFilter.moon b/modules/l0/DependencyControl/test/ScriptTargetFilter.moon new file mode 100644 index 0000000..bdad014 --- /dev/null +++ b/modules/l0/DependencyControl/test/ScriptTargetFilter.moon @@ -0,0 +1,71 @@ +-- ScriptTargetFilter tests: include/exclude rules, matching, and fluent construction. +-- Called from test.moon as: (controls\requireTest "ScriptTargetFilter")! +() -> + Common = require "l0.DependencyControl.Common" + ScriptTargetFilter = require "l0.DependencyControl.ScriptTargetFilter" + Module = Common.ScriptType.Module + Automation = Common.ScriptType.Automation + + { + _description: "Tests for ScriptTargetFilter: include/exclude rules, matching, and chaining." + + include_singleNamespace: (ut) -> + f = ScriptTargetFilter!\include Module, "l0.DependencyControl" + ut\assertTrue f\matches Module, "l0.DependencyControl" + ut\assertFalse f\matches Module, "l0.Other" + ut\assertFalse f\matches Automation, "l0.DependencyControl" + + includeAll_singleType: (ut) -> + f = ScriptTargetFilter!\includeAll Module + ut\assertTrue f\matches Module, "anything" + ut\assertFalse f\matches Automation, "anything" + + includeAll_everything: (ut) -> + f = ScriptTargetFilter!\includeAll! + ut\assertTrue f\matches Module, "x" + ut\assertTrue f\matches Automation, "y" + + matches_noRuleIsFalse: (ut) -> + ut\assertFalse ScriptTargetFilter!\matches Module, "x" + + exclude_takesPrecedenceOverAll: (ut) -> + f = ScriptTargetFilter!\includeAll(Module)\exclude Module, "l0.Skip" + ut\assertTrue f\matches Module, "l0.Keep" + ut\assertFalse f\matches Module, "l0.Skip" + + exclude_overridesInclude: (ut) -> + f = ScriptTargetFilter!\include(Module, "l0.X")\exclude Module, "l0.X" + ut\assertFalse f\matches Module, "l0.X" + + chaining_returnsSelf: (ut) -> + f = ScriptTargetFilter! + ut\assertEquals f\include(Module, "a"), f + ut\assertEquals f\includeAll(Module), f + ut\assertEquals f\exclude(Module, "b"), f + + scriptTypes_listsTypesWithRules: (ut) -> + types = ScriptTargetFilter!\includeAll(Module)\scriptTypes! + ut\assertEquals #types, 1 + ut\assertEquals types[1], Module + + scriptTypes_empty: (ut) -> + ut\assertEquals #(ScriptTargetFilter!\scriptTypes!), 0 + + new_fromSpecBooleanAll: (ut) -> + f = ScriptTargetFilter {[Module]: true} + ut\assertTrue f\matches Module, "x" + ut\assertFalse f\matches Automation, "x" + + new_fromSpecIncludeExclude: (ut) -> + f = ScriptTargetFilter {[Module]: {include: {"l0.A", "l0.B"}, exclude: {"l0.B"}}} + ut\assertTrue f\matches Module, "l0.A" + ut\assertFalse f\matches Module, "l0.B" + ut\assertFalse f\matches Module, "l0.C" + + _order: { + "include_singleNamespace", "includeAll_singleType", "includeAll_everything", + "matches_noRuleIsFalse", "exclude_takesPrecedenceOverAll", "exclude_overridesInclude", + "chaining_returnsSelf", "scriptTypes_listsTypesWithRules", "scriptTypes_empty", + "new_fromSpecBooleanAll", "new_fromSpecIncludeExclude" + } + } diff --git a/modules/l0/DependencyControl/test/UpdateFeed.moon b/modules/l0/DependencyControl/test/UpdateFeed.moon new file mode 100644 index 0000000..6ce3d1b --- /dev/null +++ b/modules/l0/DependencyControl/test/UpdateFeed.moon @@ -0,0 +1,165 @@ +-- Additional UpdateFeed tests: getModuleVersion, getFileDeployPath, walkFiles, deployFiles. +-- Called from Tests.moon as: (require "...test.UpdateFeed") basePath, DepCtrl +(basePath, DepCtrl) -> + Common = require "l0.DependencyControl.Common" + FileOps = require "l0.DependencyControl.FileOps" + UpdateFeed = require "l0.DependencyControl.UpdateFeed" + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + + { + _description: "Additional UpdateFeed tests: getModuleVersion, getFileDeployPath, walkFiles, deployFiles." + + -- getModuleVersion + + getModuleVersion_defaultChannel: (ut) -> + feed = { + data: {modules: {"test.NS": {channels: { + release: {default: true, version: "1.2.3", files: {}} + nightly: {version: "2.0.0", files: {}} + }}}}, + __class: UpdateFeed + } + ut\assertEquals UpdateFeed.getModuleVersion(feed, "test.NS"), "1.2.3" + + getModuleVersion_fallback: (ut) -> + feed = { + data: {modules: {"test.NS": {channels: {alpha: {version: "2.0.0", files: {}}}}}}, + __class: UpdateFeed + } + ut\assertEquals UpdateFeed.getModuleVersion(feed, "test.NS"), "2.0.0" + + getModuleVersion_missing: (ut) -> + feed = {data: {modules: {}}, __class: UpdateFeed} + ut\assertNil UpdateFeed.getModuleVersion feed, "no.Such.NS" + + -- getFileDeployPath + + getFileDeployPath_module: (ut) -> + (ut\stub aegisub, "decode_path")\calls (path) -> path\gsub("^%?user", basePath) + result = UpdateFeed.getFileDeployPath UpdateFeed, "l0.NS", Common.ScriptType.Module, "/NS.moon", "script", "?user" + ut\assertString result + ut\assertContains result, "NS.moon" + ut\assertContains result, "l0" + + getFileDeployPath_test: (ut) -> + (ut\stub aegisub, "decode_path")\calls (path) -> path\gsub("^%?user", basePath) + result = UpdateFeed.getFileDeployPath UpdateFeed, "l0.NS", Common.ScriptType.Module, "/NS.moon", "test", "?user" + ut\assertString result + ut\assertContains result, "DepUnit" + + -- walkFiles + + walkFiles_yieldsProxies: (ut) -> + feed = { + data: { + modules: {"test.NS": {channels: {release: {version: "1.0.0", + files: {{name: "NS.moon", localFileBasePath: "./"}}}}}}, + macros: {} + }, + feedDir: basePath, + __class: UpdateFeed + } + results = {} + for file, channel, pkg, section, scriptType in UpdateFeed.walkFiles(feed) + results[#results + 1] = {:file, :channel, :pkg, :section, :scriptType} + ut\assertEquals #results, 1 + ut\assertEquals results[1].pkg.namespace, "test.NS" + ut\assertEquals results[1].channel.name, "release" + ut\assertEquals results[1].file.name, "NS.moon" + ut\assertEquals results[1].section, "modules" + ut\assertEquals results[1].scriptType, Common.ScriptType.Module + + walkFiles_localFilePath: (ut) -> + feed = { + data: { + modules: {"test.NS": {channels: {release: {version: "1.0.0", + files: {{name: "NS.moon", localFileBasePath: "./"}}}}}}, + macros: {} + }, + feedDir: basePath, + __class: UpdateFeed + } + for file in UpdateFeed.walkFiles(feed) + ut\assertString file.localFilePath + ut\assertContains file.localFilePath, "NS.moon" + break + + -- deployFiles + + deployFiles_copiesToDist: (ut) -> + feed = { + data: {modules: {}, macros: {}}, + feedDir: basePath, logger: DepCtrl.logger, __class: UpdateFeed + } + srcPath = "#{basePath}/NS.moon" + dstPath = "#{basePath}/dst/NS.moon" + fakeFile = setmetatable {}, {__index: (_, k) -> + if k == "localFilePath" then srcPath + elseif k == "name" then "NS.moon" + elseif k == "type" then "script" + } + fakeChan = setmetatable {}, {__index: (_, k) -> k == "name" and "release" or nil} + fakePkg = setmetatable {}, {__index: (_, k) -> k == "namespace" and "test.NS" or nil} + (ut\stub feed, "walkFiles")\calls (self, scriptTypes) -> + coroutine.wrap -> + coroutine.yield fakeFile, fakeChan, fakePkg, "modules", Common.ScriptType.Module + (ut\stub FILEOPS_MODULE_NAME, "exists")\returns true + (ut\stub UpdateFeed, "getFileDeployPath")\returns dstPath + (ut\stub FILEOPS_MODULE_NAME, "mkdir")\returns true + copyStub = (ut\stub FILEOPS_MODULE_NAME, "copy")\returns true + fileCount, errCount = UpdateFeed.deployFiles feed, basePath, nil, true + ut\assertEquals fileCount, 1 + ut\assertEquals errCount, 0 + copyStub\assertCalledOnce! + + deployFiles_skipExistingNoClobber: (ut) -> + feed = { + data: {modules: {}, macros: {}}, + feedDir: basePath, logger: DepCtrl.logger, __class: UpdateFeed + } + srcPath = "#{basePath}/NS.moon" + dstPath = "#{basePath}/dst/NS.moon" + fakeFile = setmetatable {}, {__index: (_, k) -> + if k == "localFilePath" then srcPath + elseif k == "name" then "NS.moon" + elseif k == "type" then "script" + } + fakeChan = setmetatable {}, {__index: (_, k) -> k == "name" and "release" or nil} + fakePkg = setmetatable {}, {__index: (_, k) -> k == "namespace" and "test.NS" or nil} + (ut\stub feed, "walkFiles")\calls (self, scriptTypes) -> + coroutine.wrap -> + coroutine.yield fakeFile, fakeChan, fakePkg, "modules", Common.ScriptType.Module + (ut\stub FILEOPS_MODULE_NAME, "exists")\returns true + (ut\stub UpdateFeed, "getFileDeployPath")\returns dstPath + copyStub = (ut\stub FILEOPS_MODULE_NAME, "copy")\returns true + fileCount, errCount = UpdateFeed.deployFiles feed, basePath + ut\assertEquals fileCount, 0 + ut\assertEquals errCount, 0 + copyStub\assertNotCalled! + + deployFiles_countsMissingSource: (ut) -> + feed = { + data: {modules: {}, macros: {}}, + feedDir: basePath, logger: DepCtrl.logger, __class: UpdateFeed + } + fakeFile = setmetatable {}, {__index: (_, k) -> + if k == "localFilePath" then nil + elseif k == "name" then "NS.moon" + } + fakeChan = setmetatable {}, {__index: (_, k) -> k == "name" and "release" or nil} + fakePkg = setmetatable {}, {__index: (_, k) -> k == "namespace" and "test.NS" or nil} + (ut\stub feed, "walkFiles")\calls (self, scriptTypes) -> + coroutine.wrap -> + coroutine.yield fakeFile, fakeChan, fakePkg, "modules", Common.ScriptType.Module + fileCount, errCount = UpdateFeed.deployFiles feed, basePath + ut\assertEquals fileCount, 0 + ut\assertEquals errCount, 1 + + _order: { + "getModuleVersion_defaultChannel", "getModuleVersion_fallback", "getModuleVersion_missing", + "getFileDeployPath_module", "getFileDeployPath_test", + "walkFiles_yieldsProxies", "walkFiles_localFilePath", + "deployFiles_copiesToDist", "deployFiles_skipExistingNoClobber", + "deployFiles_countsMissingSource" + } + } diff --git a/schemas/feed/v0.3.0.json b/schemas/feed/v0.4.0.json similarity index 86% rename from schemas/feed/v0.3.0.json rename to schemas/feed/v0.4.0.json index c92fe89..6171855 100644 --- a/schemas/feed/v0.3.0.json +++ b/schemas/feed/v0.4.0.json @@ -1,14 +1,14 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/feed/v0.3.0.json", - "title": "DependencyControl Feed Format v0.3.0", + "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/feed/v0.4.0.json", + "title": "DependencyControl Feed Format v0.4.0", "description": "An index for a repository of automation macros and modules available for installation or update via DependencyControl.", "type": "object", "required": ["dependencyControlFeedFormatVersion", "name"], "properties": { "dependencyControlFeedFormatVersion": { - "enum": ["0.2.0", "0.3.0"], - "description": "Feed format version. This schema accepts '0.2.0' and '0.3.0'; both are handled correctly by a v0.3.0-capable reader. '0.3.0' introduced FeedFile.type for test files." + "enum": ["0.2.0", "0.3.0", "0.4.0"], + "description": "Feed format version. This schema accepts '0.2.0', '0.3.0' and '0.4.0'; all are handled correctly by a v0.4.0-capable reader, as each version only adds backwards-compatible, optional fields. '0.3.0' introduced PackagedFile.type for test files; '0.4.0' added the optional localFileBasePath/localFilePath fields used by CI/CLI tooling." }, "name": { "description": "Human-readable name of the feed (e.g. repository or collection name).", @@ -30,6 +30,11 @@ "$ref": "#/$defs/TemplateString", "description": "Default base URL for file downloads. Becomes a rolling @{fileBaseUrl} variable: once set it propagates down to all channels that do not define their own fileBaseUrl." }, + "localFileBasePath": { + "$ref": "#/$defs/TemplateString", + "description": "Local-tooling counterpart of `@{fileBaseUrl}`. Rolling base path used only when a feed is expanded in 'local' mode, where file paths are resolved on disk instead of as download URLs. Added in v0.4.0", + "default": "./" + }, "maintainer": { "description": "Name of the person or organization maintaining this feed.", "type": "string" @@ -115,6 +120,11 @@ "description": "Absolute download URL after template expansion. Defaults to the file name appended to the file base URL when omitted.", "default": "@{fileBaseUrl}@{fileName}" }, + "localFilePath": { + "$ref": "#/$defs/TemplateString", + "description": "Local-tooling counterpart of `@{url}`. Used only when a feed is expanded in 'local' mode, where file paths are resolved on disk instead of as download URLs. Added in v0.4.0", + "default": "@{localFileBasePath}@{fileName}" + }, "sha1": { "$ref": "#/$defs/Sha1Hash", "description": "Expected SHA-1 digest of the downloaded file. Used to verify integrity after download." @@ -188,6 +198,10 @@ "$ref": "#/$defs/TemplateString", "description": "Base URL prepended to each file's name to form its download URL. Overrides any `@{fileBaseUrl}` template variable inherited from the script entry or feed root. This is a 'rolling' template variable: once set at a given depth it propagates to all nested levels until overridden." }, + "localFileBasePath": { + "$ref": "#/$defs/TemplateString", + "description": "Local-tooling counterpart of `@{fileBaseUrl}`. Rolling base path used only when a feed is expanded in 'local' mode, where file paths are resolved on disk instead of as download URLs. Added in v0.4.0" + }, "files": { "description": "Files included in this release.", "type": "array", @@ -232,6 +246,10 @@ "$ref": "#/$defs/TemplateString", "description": "Default base URL for file downloads within this script. Overrides the feed-level `@{fileBaseUrl}` for this entry's channels." }, + "localFileBasePath": { + "$ref": "#/$defs/TemplateString", + "description": "Local-tooling counterpart of `@{fileBaseUrl}`. Rolling base path used only when a feed is expanded in 'local' mode, where file paths are resolved on disk instead of as download URLs. Added in v0.4.0" + }, "channels": { "description": "Available release channels keyed by channel name.", "type": "object", From 44a34c22c7727814526f9acc8220c66667cea9a0 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 6 Jun 2026 22:06:21 +0200 Subject: [PATCH 41/47] fix: feed schema inaccuracies --- schemas/feed/v0.4.0.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/schemas/feed/v0.4.0.json b/schemas/feed/v0.4.0.json index 6171855..49afc9d 100644 --- a/schemas/feed/v0.4.0.json +++ b/schemas/feed/v0.4.0.json @@ -82,10 +82,11 @@ }, "DateOrDateTime": { - "description": "A date or date-time string in ISO 8601 format (e.g. '2024-01-31' or '2024-01-31T23:59:00Z').", + "description": "A date or date-time string in ISO 8601 format (e.g. '2024-01-31' or '2024-01-31T23:59:00Z'), or null to indicate an unreleased/pending build.", "anyOf": [ { "type": "string", "format": "date" }, - { "type": "string", "format": "date-time" } + { "type": "string", "format": "date-time" }, + { "type": "null" } ] }, @@ -134,6 +135,11 @@ "type": "string", "enum": ["test"] }, + "delete": { + "description": "When true, the Updater will delete this file from the user's installation when processing an update to this channel, rather than downloading it. Use this to remove files that were shipped in a previous release but are no longer part of the package.", + "type": "boolean", + "default": false + }, "platform": { "description": "Target platform filter. When present, the file is only installed on matching platforms. Absent means the file is installed on all platforms. Uses LuaJIT `jit.os`and `jit.arch` values (see https://luajit.org/ext_jit.html#jit_os).", "type": "string", From a0b300a231efc76058f977cc8841a28243cbb22d Mon Sep 17 00:00:00 2001 From: line0 Date: Mon, 8 Jun 2026 00:46:28 +0200 Subject: [PATCH 42/47] feat: update hashes and package metadata in feeds; JSON Schema Validator --- .github/workflows/test.yml | 3 + DependencyControl.json | 290 +++++++++---- depctrl.lua | 62 +++ macros/l0.DependencyControl.Toolbox.moon | 7 +- modules/l0/DependencyControl/Common.moon | 28 ++ modules/l0/DependencyControl/JsonSchema.moon | 184 ++++++++ .../l0/DependencyControl/ModuleLoader.moon | 12 +- .../l0/DependencyControl/ModuleProvider.moon | 33 +- modules/l0/DependencyControl/Record.moon | 36 +- .../DependencyControl/SemanticVersioning.moon | 18 + modules/l0/DependencyControl/UpdateFeed.moon | 402 ++++++++++++++++-- modules/l0/DependencyControl/test.moon | 69 ++- modules/l0/DependencyControl/test/Common.moon | 30 +- .../l0/DependencyControl/test/JsonSchema.moon | 71 ++++ .../test/ModuleProvider.moon | 67 +++ .../l0/DependencyControl/test/UpdateFeed.moon | 112 ++++- modules/l0/dkjson.moon | 104 ++++- schemas/feed/v0.4.0.json | 11 +- 18 files changed, 1355 insertions(+), 184 deletions(-) create mode 100644 modules/l0/DependencyControl/JsonSchema.moon create mode 100644 modules/l0/DependencyControl/test/JsonSchema.moon create mode 100644 modules/l0/DependencyControl/test/ModuleProvider.moon diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6d4a62..8c0f14b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,9 @@ jobs: - run: luarocks install luasocket - run: luarocks install copas - run: luarocks install pegasus + # json schema validation + - run: luarocks install lua-schema + - run: luarocks install lpeg - name: Run tests timeout-minutes: 5 diff --git a/DependencyControl.json b/DependencyControl.json index e623cef..71523c0 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -7,40 +7,40 @@ "fileBaseUrl": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/", "maintainer": "line0", "knownFeeds": { - "line0scripts": "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/DependencyControl.json", "a-mo": "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json", - "SubInspector": "https://raw.githubusercontent.com/TypesettingTools/SubInspector/master/DependencyControl.json", + "arch1t3cht-scripts": "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", "ASSFoundation": "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json", + "coffeeflux-scripts": "https://raw.githubusercontent.com/TypesettingTools/CoffeeFlux-Aegisub-Scripts/master/DependencyControl.json", "ffi-experiments": "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json", + "ILL": "https://raw.githubusercontent.com/TypesettingTools/ILL-Aegisub-Scripts/main/DependencyControl.json", + "line0scripts": "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/DependencyControl.json", "lyger-scripts": "https://raw.githubusercontent.com/TypesettingTools/lyger-Aegisub-Scripts/master/DependencyControl.json", - "unanimated-scripts": "https://raw.githubusercontent.com/TypesettingTools/unanimated-Aegisub-Scripts/master/DependencyControl.json", - "coffeeflux-scripts": "https://raw.githubusercontent.com/TypesettingTools/CoffeeFlux-Aegisub-Scripts/master/DependencyControl.json", "myaamori-scripts": "https://raw.githubusercontent.com/TypesettingTools/Myaamori-Aegisub-Scripts/master/DependencyControl.json", "petzku-scripts": "https://raw.githubusercontent.com/petzku/Aegisub-Scripts/master/DependencyControl.json", - "zahuczky-scripts": "https://raw.githubusercontent.com/Zahuczky/Zahuczkys-Aegisub-Scripts/main/DependencyControl.json", "phoscity-scripts": "https://raw.githubusercontent.com/PhosCity/Aegisub-Scripts/main/DependencyControl.json", - "zeref-scripts": "https://raw.githubusercontent.com/TypesettingTools/zeref-Aegisub-Scripts/main/DependencyControl.json", - "arch1t3cht-scripts": "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", - "ILL": "https://raw.githubusercontent.com/TypesettingTools/ILL-Aegisub-Scripts/main/DependencyControl.json" + "SubInspector": "https://raw.githubusercontent.com/TypesettingTools/SubInspector/master/DependencyControl.json", + "unanimated-scripts": "https://raw.githubusercontent.com/TypesettingTools/unanimated-Aegisub-Scripts/master/DependencyControl.json", + "zahuczky-scripts": "https://raw.githubusercontent.com/Zahuczky/Zahuczkys-Aegisub-Scripts/main/DependencyControl.json", + "zeref-scripts": "https://raw.githubusercontent.com/TypesettingTools/zeref-Aegisub-Scripts/main/DependencyControl.json" }, "macros": { "l0.DependencyControl.Toolbox": { - "url": "@{baseUrl}#@{namespace}", - "author": "line0", "name": "DependencyControl Toolbox", "description": "Provides DependencyControl maintenance and configuration utilities.", + "author": "line0", + "url": "@{baseUrl}#@{namespace}", "fileBaseUrl": "@{fileBaseUrl}macros-v@{version}-@{channel}/macros/@{namespace}", "localFileBasePath": "@{localFileBasePath}macros/@{namespace}", "channels": { "alpha": { "version": "0.2.0", - "released": "2026-05-23", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "5F7E0EEFC89E71F427819EEF69630455C0CC2304" + "sha1": "077264D7771AAB8384F8AD3BDCC9040F606D5591" } ], "requiredModules": [ @@ -63,126 +63,242 @@ "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." - ] + "0.2.0": ["Now registers the DepCtrl-internal test suite as a macro."] } } }, "modules": { "l0.DependencyControl": { - "url": "@{baseUrl}#@{namespace}", - "author": "line0", "name": "DependencyControl", "description": "Dependency manager and automatic script updater for Aegisub macros and modules.", + "author": "line0", + "url": "@{baseUrl}#@{namespace}", "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}", "localFileBasePath": "@{localFileBasePath}modules/@{namespacePath}", "channels": { "alpha": { "version": "0.7.0", - "released": "2026-05-23", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "36104C47B776412EBF36AAA00D583180BF4507D5" + "sha1": "688A395AE62742B496633959BA8FECCDC8E49544" }, { "name": "/Common.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "7262886AEB9F106E95697E86FF0D44738415DBA6" + "sha1": "6AB31EB4EDEA50A1BAB047645E18274EC8AB1FDB" }, { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1FEC3583C37E4A997E806D5B17A338390657BA53" + "sha1": "AC4B013B7A352B020A8B9FF90E081BC602F0D292" + }, + { + "name": "/ConfigView.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "4CEC6275CB7D0BC6F19BE79A320679B47A9A05AB" + }, + { + "name": "/Crypto.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "143EBF2D4E7B99960193FD16637059086784819C" + }, + { + "name": "/Downloader.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "E1D05400E021109BAA128BF6BBB9048ACBE63639" + }, + { + "name": "/DownloadManager.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "989E147B32287EAC26456266402C6C69877EE121" + }, + { + "name": "/Enum.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "468E8243882CBF0192DF105F781F911EDE6184FE" + }, + { + "name": "/EventEmitter.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "3699FF01202D89D227BBE214E37DA056A93DED20" }, { "name": "/FileOps.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "5A54D4B942F34C005ABC977B7655C2B849EC8889" + "sha1": "37ADA9D77A11519384511D048E4EC807470424A4" + }, + { + "name": "/GitRepository.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "A4C19B95FC9B218CB5C630883141502A8C6C4D20" + }, + { + "name": "/Lock.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "68515CB1EA45191529EF4A75F0E88387A9085F15" }, { "name": "/Logger.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "C4980A42A5AE9C8E24BE04DD12006D118606DBA1" + "sha1": "28A6A8774007AEE99F9529BC2491A688DE68BB85" }, { "name": "/ModuleLoader.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "F35D88A9902FF9BC912D34299733D37FC15A36DF" + "sha1": "6CD3DEEEB8472768B537A4F15EEC5AE6A095CF62" + }, + { + "name": "/ModuleProvider.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "A938976FFF31034738D99B87F1BC08A301496BDC" }, { "name": "/Record.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "796A430D14CACA3E2E15DBDD23F01DC4DC9E4B19" + "sha1": "C37CFF46C6FB12485D8FE0D59DD2E6B6AFF414BE" + }, + { + "name": "/ScriptTargetFilter.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "7F766A279DC8E5EDF597A081D827D8C4ED5D0019" + }, + { + "name": "/ScriptUpdateRecord.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "16C3B016D33C95BF7AE794F07FFB093896F620BE" }, { "name": "/SemanticVersioning.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "C8DE63A2BE75B1135CEED3ED4ADF7025C927706C" + "sha1": "81D572B7174FDEE2C391C0C3CA70C8CB36CC93A7" + }, + { + "name": "/Stub.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "F48B4E3EBA9ADBB22745714060B454C22CE75AB9" + }, + { + "name": "/TerribleMutex.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "6B1C4E43BB588DDD2DE0FF730F3F288C641C535E" + }, + { + "name": "/Timer.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "5E3C81C8D90BC8D04D2A9B42494506F66381142D" }, { "name": "/UnitTestSuite.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "BF316812E9ACF6C73570337C2FCA89FD33189A2B" + "sha1": "046772463E64D609B41A7457552B1E2E2A12C964" }, { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "7B64A01259AAA32E963708AE26BCF090AFC1E0DD" + "sha1": "D550DBC51FC0398F5FD7A07299BB402AF33D2152" }, { "name": "/Updater.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "6647D7CAB70637E2B961EF334153718B06EA1027" + "sha1": "E8A98A4D2C27C75DE8E8D06903722F674DE81715" + }, + { + "name": "/ZipArchiver.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "93A4D958835DECFE594F6D8CAEF050992523E4DB" }, { "name": ".moon", - "type": "test", "url": "@{fileBaseUrl}/test.moon", - "sha1": "1ED8961CAFCADA7E4C04778227EECDC18E509B8D" + "sha1": "688A395AE62742B496633959BA8FECCDC8E49544", + "type": "test" + }, + { + "name": "/test/Common.moon", + "url": "@{fileBaseUrl}@{fileName}n", + "sha1": "2915E7F04AAA17C510576E8E28D9EC2CE06B6937", + "type": "test" + }, + { + "name": "/test/FileOps.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "50021BFAB752397D1658158914187CFADEBFAC80", + "type": "test" + }, + { + "name": "/test/GitRepository.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "F7E43D74EF6574408DFD1B851EF40F18BDB64FFA", + "type": "test" + }, + { + "name": "/test/ScriptTargetFilter.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "278632008FE11ADB967B4761A2BFD1AB344C12B8", + "type": "test" + }, + { + "name": "/test/UpdateFeed.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "9968EC4BED9C6D698DE5E99ADA363DC2E1C7DDDE", + "type": "test" + }, + { + "name": "/test/helpers/mock-http-server.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "D6D87B5EA2F188EA5FF6D9479412FF5F9CE99EAE", + "type": "test" + }, + { + "name": "/test/helpers/MockHttpServerController.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "CE361BB092ECBD1671B579CDF6A3FA181F4EC737", + "type": "test" } ], "requiredModules": [ { "moduleName": "requireffi.requireffi", "version": "0.1.1", + "optional": true, "feed": "@{feed:ffi-experiments}" } ] } }, "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.5.0": [ + "DependencyControl does now auto-update itself and its dependencies.", + "Provided Sub-Modules (Logger, ConfigHandler, ...) can now easily be accessed as class properties of the main DependencyControl module.", + "A bug was fixed that caused macros always being registered with the overall script description, ignoring specific descriptions for the macro menu entries.", + "The \\getConfigHandler() method no longer ignores the defaults parameter.", + "Fixed a FileOps bug that would cause path validation to fail on paths relative to the working directory.", + "ConfigHandler: writes to the configuration table are no longer accidentally routed to the defaults table when a value is updated that only exists in the Defaults.", + "ConfigHandler: Looping over the configuration table is now completely transparent wrt which fields are user configuration or defaults.", + "ConfigHandler: fixed a bug that prevented a global lock on the config file from being released on certain error conditions.", + "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." ], - "0.6.3": [ - "Fixed a v0.6.2 regression that caused DependencyControl to fail loading the first time after a scheduled self-update." + "0.5.1": [ + "Macros registered using DependencyControl now get passed the previously missing 'active_line' parameter.", + "Fixed a bug that would cause an unrelated error to be thrown in place of the real error message when an updated module failed to load." ], - "0.6.2": [ - "An issue was fixed that would cause DepCtrl initializer code in modules previously loaded with regular Lua loading mechanisms to be skipped when requested in a _DependencyControl_- context. This kept the [requireffi](https://github.com/torque/ffi-experiments/tree/master/requireffi) _DependencyControl_ record from being established, preventing any updates from taking place.", - "UnitTestSuite: Fixed several broken assertions and related error messages, among them the `assertMatches` and `assertErrorMatches` assertions always returning `true`. Please make sure to rerun your tests after upgrading to confirm your tested actually return values whatever they were supposed to match.", - "Updater: Identical or duplicate feeds from different sources (user configuration, feeds and API use) are no longer being checked for updates multiple times.", - "Updater / FileOps: Fixed several broken error messages and return values." + "0.5.2": [ + "Updates and installations no longer fail when no suitable version of a module marked as an optional dependency can be found.", + "ConfigHandlers now recover gracefully when a corrupted config is encountered.", + "Fixed a bug that may have caused updates of unmanaged modules to throw an error after completion.", + "DependencyControl initialization functions in modules with optional DepCtrl support are now expected to use the predefined name __depCtrlInit. This lifts the unreasonable requirement of having to specify the name of the function in the dependency tables of the loading scripts. By extension, this also fixes errors when trying to update the binary modules required by DependencyControl (such as DownloadManager).", + "The Updater now checks for an active internet connection before going ahead with downloading feeds and packages.", + "FileOps: added a copy function for files." ], - "0.6.1": [ - "The Updater component now supports the DownloadManager v0.4.0 API changes.", - "Updater: A regression introduced in v0.6.0 was fixed that caused update or installation processes to fail when the feed contained deletion records.", - "FileOps.mkdir() no longer falsely retuns an error state when a path to an existing file is passed with the `isFile` flag set." + "0.5.3": [ + "ConfigHandler: A host of longstanding issues related to config file corruption and concurrent access to config files from multiple DepCtrl-hosting automation scripts have been fixed.", + "Error Reports of required modules loaded by DependencyControl now actually provide semi-useful stack traces.", + "A bug was fixed that could cause DepCtrl to rerun the __depCtrlInit method on modules even though a prior DependencyControl record had already been initialized.", + "The DependencyControl self-update now runs through properly without throwing an error at the end of the process." ], "0.6.0": [ "The UnitTestSuite framework for automatically testing automation scripts and modules has been added.", @@ -194,54 +310,54 @@ "FileOps.move() no longer fails to move files across file systems on *nix operating systems and properly cleans up after itself if files could not be overwritten and were renamed instead.", "FileOps: path validation is no longer broken on non-windows systems" ], - "0.5.3": [ - "ConfigHandler: A host of longstanding issues related to config file corruption and concurrent access to config files from multiple DepCtrl-hosting automation scripts have been fixed.", - "Error Reports of required modules loaded by DependencyControl now actually provide semi-useful stack traces.", - "A bug was fixed that could cause DepCtrl to rerun the __depCtrlInit method on modules even though a prior DependencyControl record had already been initialized.", - "The DependencyControl self-update now runs through properly without throwing an error at the end of the process." + "0.6.1": [ + "The Updater component now supports the DownloadManager v0.4.0 API changes.", + "Updater: A regression introduced in v0.6.0 was fixed that caused update or installation processes to fail when the feed contained deletion records.", + "FileOps.mkdir() no longer falsely retuns an error state when a path to an existing file is passed with the `isFile` flag set." ], - "0.5.2": [ - "Updates and installations no longer fail when no suitable version of a module marked as an optional dependency can be found.", - "ConfigHandlers now recover gracefully when a corrupted config is encountered.", - "Fixed a bug that may have caused updates of unmanaged modules to throw an error after completion.", - "DependencyControl initialization functions in modules with optional DepCtrl support are now expected to use the predefined name __depCtrlInit. This lifts the unreasonable requirement of having to specify the name of the function in the dependency tables of the loading scripts. By extension, this also fixes errors when trying to update the binary modules required by DependencyControl (such as DownloadManager).", - "The Updater now checks for an active internet connection before going ahead with downloading feeds and packages.", - "FileOps: added a copy function for files." + "0.6.2": [ + "An issue was fixed that would cause DepCtrl initializer code in modules previously loaded with regular Lua loading mechanisms to be skipped when requested in a _DependencyControl_- context. This kept the [requireffi](https://github.com/torque/ffi-experiments/tree/master/requireffi) _DependencyControl_ record from being established, preventing any updates from taking place.", + "UnitTestSuite: Fixed several broken assertions and related error messages, among them the `assertMatches` and `assertErrorMatches` assertions always returning `true`. Please make sure to rerun your tests after upgrading to confirm your tested actually return values whatever they were supposed to match.", + "Updater: Identical or duplicate feeds from different sources (user configuration, feeds and API use) are no longer being checked for updates multiple times.", + "Updater / FileOps: Fixed several broken error messages and return values." ], - "0.5.1": [ - "Macros registered using DependencyControl now get passed the previously missing 'active_line' paramter.", - "Fixed a bug that would cause an unrelated error to be thrown in place of the real error message when an updated module failed to load." + "0.6.3": [ + "Fixed a v0.6.2 regression that caused DependencyControl to fail loading the first time after a scheduled self-update." ], - "0.5.0": [ - "DependencyControl does now auto-update itself and its dependencies.", - "Provided Sub-Modules (Logger, ConfigHandler, ...) can now easily be accessed as class properties of the main DependencyControl module.", - "A bug was fixed that caused macros always being registered with the overall script description, ignoring specific descriptions for the macro menu entries.", - "The \\getConfigHandler() method no longer ignores the defaults parameter.", - "Fixed a FileOps bug that would cause path validation to fail on paths relative to the working directory.", - "ConfigHandler: writes to the configuration table are no longer accidentally routed to the defaults table when a value is updated that only exists in the Defaults.", - "ConfigHandler: Looping over the configuration table is now completely transparent wrt which fields are user configuration or defaults.", - "ConfigHandler: fixed a bug that prevented a global lock on the config file from being released on certain error conditions.", - "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." + "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.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." ] } }, "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.", + "author": "David Kolf", + "url": "http://dkolf.de/dkjson-lua/", "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}", "localFileBasePath": "@{localFileBasePath}modules/@{namespacePath}", "channels": { "release": { "version": "2.10.0", - "released": "2026-05-30", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "94DDD16A2B34530F50F664F6D0F7A4B6B962A886" + "sha1": "9805D5DF27A7C12DCFF4D420B320085D1E8315EB" }, { "name": "/vendor/dkjson.lua", @@ -252,7 +368,9 @@ } }, "changelog": { - "2.10.0": ["Vendored dkjson v2.10 with a DependencyControl version record and json/dkjson self-registration."] + "2.10.0": [ + "Vendored dkjson v2.10 with a DependencyControl version record and json/dkjson self-registration." + ] } } } diff --git a/depctrl.lua b/depctrl.lua index eb31c7e..69cd6f4 100644 --- a/depctrl.lua +++ b/depctrl.lua @@ -59,6 +59,14 @@ deployCmd:flag("--clobber", "Overwrite existing files (default)"):target("clo deployCmd:flag("--no-clobber", "Skip files that already exist at the destination"):target("clobber"):action("store_false") addTargets(deployCmd) +local updateFeedCmd = parser:command("update-feed", + "Refresh SHA-1 hashes, version info, and file presence in a feed channel") +updateFeedCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json") +updateFeedCmd:option("-c --channel", "Channel to update (default: the channel marked default: true)") + :argname("") +updateFeedCmd:flag("-n --dry-run", "Print what would change without writing back") +addTargets(updateFeedCmd) + local args = parser:parse() -- ── Resolve the launcher directory ─────────────────────────────────────────── @@ -304,4 +312,58 @@ elseif args.command == "deploy" then local status = fileCount > 0 and "Deploy complete" or "Deploy produced no files" io.stdout:write(("\n%s: %d file(s) deployed to %s, %d error(s)\n"):format(status, fileCount, outputDir, errCount)) os.exit(errCount > 0 and 1 or 0) + +-- ─── update-feed ────────────────────────────────────────────────────────────── +elseif args.command == "update-feed" then + local feedPath = resolveAbsPath(args.feed) + + setupDepCtrl("update-feed") + + local UpdateFeed = require "l0.DependencyControl.UpdateFeed" + local feed = UpdateFeed(nil, false, feedPath) + + registerFeedSearcher(feed) + + local stats, err = feed:updateFeed({ + channel = args.channel, + filter = buildFilter(args), + schemaDir = table.concat({ launcherDir, "schemas", "feed" }, pathSep), + outPath = args.dry_run and false or nil, + }) + + if not stats then + io.stderr:write("Error updating feed: " .. tostring(err) .. "\n") + os.exit(1) + end + + -- Per-package breakdown: one status line per package, with any errors indented beneath it. + local changedWord = args.dry_run and "would change" or "updated" + for _, pkg in ipairs(stats.packages) do + local label = pkg.namespace .. (pkg.channel and (" (" .. pkg.channel .. ")") or "") + local status + if #pkg.errors > 0 then + status = ("%d error%s"):format(#pkg.errors, #pkg.errors == 1 and "" or "s") + elseif pkg.changed then + status = changedWord + else + status = "no changes" + end + io.stdout:write((" %-48s %s\n"):format(label, status)) + for _, e in ipairs(pkg.errors) do + io.stderr:write(" ! " .. (tostring(e):gsub("\n", "\n ")) .. "\n") + end + end + + -- Summary + local total = #stats.packages + if stats.changed > 0 then + local verb = args.dry_run and "would change — dry run, nothing written" or ("updated in " .. feedPath) + io.stdout:write(("\n%d of %d package(s) %s\n"):format(stats.changed, total, verb)) + else + io.stdout:write("\nFeed is already up to date.\n") + end + if stats.errored > 0 then + io.stdout:write(("%d package(s) had errors (see above).\n"):format(stats.errored)) + end + os.exit(stats.errored > 0 and 1 or 0) end diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index 7e674ef..ea8aebf 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -5,7 +5,12 @@ export script_author = "line0" export script_namespace = "l0.DependencyControl.Toolbox" DepCtrl = require "l0.DependencyControl" -depRec = DepCtrl feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json" +depRec = DepCtrl { + feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json", + { + {"l0.DependencyControl", version: "0.7.0"} + } +} logger = DepCtrl.logger logger.usePrefixWindow = false diff --git a/modules/l0/DependencyControl/Common.moon b/modules/l0/DependencyControl/Common.moon index 6348d62..02d5fad 100644 --- a/modules/l0/DependencyControl/Common.moon +++ b/modules/l0/DependencyControl/Common.moon @@ -1,4 +1,23 @@ ffi = require "ffi" +Crypto = require "l0.DependencyControl.Crypto" + +-- Serializes a value into a canonical string for hashing: table keys are emitted in sorted +-- order so field ordering never affects the result, and every value is tagged with its type +-- so distinct types can't collide (e.g. the number 1 vs. the string "1"). +-- @param value any the value to canonicalize +-- @return string the canonicalized string +canonicalize = (value) -> + switch type value + when "table" + entries = {} + entries[#entries + 1] = "#{canonicalize k}=#{canonicalize v}" for k, v in pairs value + table.sort entries + "{#{table.concat entries, ","}}" + when "string" then "s:#{value}" + when "number" then "n:#{string.format "%.17g", value}" + when "boolean" then "b:#{value and 1 or 0}" + when "nil" then "nil" + else "#{type value}:#{tostring value}" -- Compares two values for deep equality. Tables are compared recursively; -- other types use == except that two identical values always compare equal. @@ -245,3 +264,12 @@ class DependencyControlCommon -- @return table flattened # A flattened array table containing the flattened values. -- @return number flattenedCount # The number of elements in the flattened array. @flatten = flatten + + --- Produces a deterministic SHA-1 hash of a (possibly nested) Lua value. + -- Table keys are sorted before hashing, so field ordering never affects the result; pass an + -- object pruned to just the fields you care about to obtain a stable content signature that + -- ignores irrelevant differences. Useful for cheaply detecting whether semantic content changed. + -- @static + -- @param value any the value to hash + -- @return string a 40-character lowercase SHA-1 hex digest + @getObjectHash = (value) -> Crypto.sha1 canonicalize value diff --git a/modules/l0/DependencyControl/JsonSchema.moon b/modules/l0/DependencyControl/JsonSchema.moon new file mode 100644 index 0000000..0afde98 --- /dev/null +++ b/modules/l0/DependencyControl/JsonSchema.moon @@ -0,0 +1,184 @@ +json = require "json" +Logger = require "l0.DependencyControl.Logger" +FileOps = require "l0.DependencyControl.FileOps" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +defaultLogger = Logger fileBaseName: "DepCtrl.JsonSchema" + +-- Lazily resolve lua-schema because it's only available on luarocks, not DepCtrl +luaSchema = nil +patternKeywordShadowed = nil + +patternNoFallbackError = "Cannot validate the `pattern` keyword '%s': rex_pcre2 is unavailable and " .. + "the schema node provides no `lpegPattern` fallback to validate it with." + +-- When rex_pcre2 is absent, lua-schema's built-in `pattern` keyword errors out on use. Shadow it +-- so that validation instead relies on a sibling `lpegPattern` keyword (checked via LPeg.re). +-- If an `lpegPattern` sibling field is missing for any `pattern`, an error is raised at schema build time. +shadowPatternKeyword = (luaSchemaLib) -> + havePcre2 = pcall require, "rex_pcre2" + if havePcre2 + patternKeywordShadowed = false + return + + -- custom_keyword takes priority over the built-in keyword of the same name + luaSchemaLib.custom_keyword["pattern"] = (patternValue, schemaNode) -> + error patternNoFallbackError\format patternValue unless schemaNode.schema.lpegPattern + -- passing the `pattern` check leaves the real validation to `lpegPattern` + (schema, _, dataPtr) -> schema\_mk_output true, nil, "pattern", dataPtr, schema.validation + patternKeywordShadowed = true + +loadLuaSchemaLib = -> + return luaSchema unless luaSchema == nil + ok, lib = pcall require, "schema" + luaSchema = ok and lib or false + shadowPatternKeyword luaSchema if luaSchema + return luaSchema + +-- Flattens lua-schema's `detailed` validation output into a list of ": " strings. +-- A failed result either carries a leaf `.error` (at `.instanceLocation`) or nests further failures +-- under `.errors`. +collectValidationErrors = (result, acc = {}) -> + if result.errors + collectValidationErrors sub, acc for sub in *result.errors + elseif result.error + acc[#acc + 1] = "#{result.instanceLocation or '?'}: #{result.error}" + return acc + +--- JSON schema loading and validation utilities. +-- Depends on the `lua-schema` library for validation, which must be manually installed +-- via LuaRocks and/or otherwise made available on the Lua path by the user. +-- @class JsonSchema +class JsonSchema + msgs = { + load: { + errors: { + read: "Couldn't read JSON schema file '%s': %s" + jsonParse: "Couldn't parse JSON schema file '%s' as JSON." + notAnObject: "JSON schema file '%s' did not decode to a JSON object (got %s).", + badArgument: "Invalid schema argument of type %s (expected table or string file path)." + } + } + getSchemasInDirectory: { + errors: { + readDir: "Couldn't read schema directory '%s': %s" + noSchemasFound: "No schema files found in directory '%s' matching pattern '%s'." + } + } + validate: { + errors: { + libMissing: "JSON schema validation requires 'lua-schema'. Manually install it via LuaRocks and/or ensure it's on the Lua path to enable validation." + genericInvalid: "Data did not conform to schema, but no specific error information is available." + } + noPcre: "rex_pcre2 not available — using LPeg.re `lpegPattern` fallback for `pattern` validation." + } + validateAny: { + errors: { + versionNotFound: "No schema available for version '%s'." + versionLoadFailed: "Failed to load schema for version '%s': %s" + validateErrored: "An error occurred while validating against schema version '%s': %s" + invalid: "Data did not validate against schema version '%s': %s" + allFailed: "Validation failed against all available schemas (feed version was '%s'). Errors by schema version:\n%s" + } + } + } + + -- Whether the no-op `pattern` keyword has already been installed (see @{validate}). + @patternKeywordShadowed = false + + @getSchemasInDirectory = (schemaDir, fileNamePattern = "^v(%d+%.%d+%.%d+)%.json$") => + schemaDirContents, listErr = FileOps.listDir schemaDir + unless schemaDirContents + return nil, msgs.getSchemasInDirectory.errors.readDir\format schemaDir, listErr + + -- map each matching file's captured version (e.g. "0.4.0") to its full path + schemaPathsByVersion, foundAny = {}, false + for fileName in *schemaDirContents + version = fileName\match fileNamePattern + if version + schemaPathsByVersion[version] = FileOps.joinPath schemaDir, fileName + foundAny = true + unless foundAny + return nil, msgs.getSchemasInDirectory.errors.noSchemasFound\format schemaDir, fileNamePattern + return schemaPathsByVersion + + @validateAny: (data, schemasByVersion, dataSchemaVersion) => + trySchemaVersion = (version) -> + entry = schemasByVersion[version] + unless entry + return nil, version, msgs.validateAny.errors.versionNotFound\format version + + -- accept either a ready JsonSchema instance or a path/table to construct one from + schema = entry + unless type(entry) == "table" and entry.__class == JsonSchema + loaded, instanceOrErr = pcall JsonSchema, entry + unless loaded + return nil, version, msgs.validateAny.errors.versionLoadFailed\format version, instanceOrErr + schema = instanceOrErr + + valid, err = schema\validate data + if valid == nil + return nil, version, msgs.validateAny.errors.validateErrored\format version, err + if valid == false + return false, version, msgs.validateAny.errors.invalid\format version, err + return true, version + + errors = {} + if dataSchemaVersion + -- try exact schema version used by the feed first + isValid, validationVersion, validationErr = trySchemaVersion dataSchemaVersion + return isValid, validationVersion, validationErr if isValid != nil + errors[validationVersion] = validationErr + + -- no exact match for the feed's version: try the other available ones, highest version + -- first to avoid skipping validation of fields not present in earlier schema versions + otherVersions = [version for version in pairs schemasByVersion when version != dataSchemaVersion] + table.sort otherVersions, SemanticVersioning.isHigher + for version in *otherVersions + isValid, validationVersion, validationErr = trySchemaVersion version + return isValid, validationVersion if isValid + errors[validationVersion] = validationErr + + return nil, nil, msgs.validateAny.errors.allFailed\format tostring(dataSchemaVersion), table.concat( + [" v#{v}: #{e}" for v, e in pairs errors], "\n") + + --- Loads and parses a JSON schema, ready to validate against. + -- @param schemaOrSchemaPath table|string the JSON schema, either as path to the schema file or a pre-parsed table + -- @param[opt] logger Logger + new: (schemaOrSchemaPath, @logger = defaultLogger) => + dataType = type schemaOrSchemaPath + @data = schemaOrSchemaPath + + -- load a schema JSON file from disk + if dataType == "string" + @schemaPath = schemaOrSchemaPath + raw, err = FileOps.readFile schemaOrSchemaPath + unless raw + @logger\error msgs.load.errors.read, schemaOrSchemaPath, err + + decoded, @data = pcall json.decode, raw + unless decoded + @logger\error msgs.load.errors.jsonParse, schemaOrSchemaPath, @data + dataType = type @data + + return if dataType == "table" + @logger\error @schemaPath and + msgs.load.errors.notAnObject\format(@schemaPath, dataType) or + msgs.load.errors.badArgument\format dataType + + --- Validates a Lua value against the loaded schema. + -- Best-effort: returns the validation result rather than raising, so callers can warn and + -- continue. Returns `nil` (plus a message) when validation couldn't be performed at all. + -- @param data table the value to validate + -- @return boolean|nil valid true/false on a completed validation, nil if it couldn't run + -- @return string|nil err the validation errors if validation failed or an error message when validation could not be performed + validate: (data) => + lib = loadLuaSchemaLib! + return nil, msgs.validate.errors.libMissing unless lib + @logger\debug msgs.validate.noPcre if @patternKeywordShadowed + + ok, result = pcall -> lib.new(@data)\validate data + return nil, result unless ok + return true if result.valid + errors = collectValidationErrors result + return false, #errors > 0 and table.concat(errors, "; ") or msgs.validate.errors.genericInvalid diff --git a/modules/l0/DependencyControl/ModuleLoader.moon b/modules/l0/DependencyControl/ModuleLoader.moon index fc9bc20..5b21195 100644 --- a/modules/l0/DependencyControl/ModuleLoader.moon +++ b/modules/l0/DependencyControl/ModuleLoader.moon @@ -3,6 +3,7 @@ -- and calling any method is guaranteed to interfere with DependencyControl operation SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" +ModuleProvider = require "l0.DependencyControl.ModuleProvider" --- Internal module loading helpers for DependencyControl-managed module dependencies. -- @class ModuleLoader @@ -55,13 +56,6 @@ class ModuleLoader 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 @@ -75,7 +69,7 @@ class ModuleLoader 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 + ModuleProvider.runInitializer ._ref, @@ return ._ref loaded, res = xpcall require, debug.traceback, moduleName @@ -92,7 +86,7 @@ class ModuleLoader ._ref, LOADED_MODULES[moduleName] = res, res -- run DepCtrl initializer if one was specified - runInitializer res + ModuleProvider.runInitializer res, @@ return mdl._ref -- having this in the with block breaks moonscript diff --git a/modules/l0/DependencyControl/ModuleProvider.moon b/modules/l0/DependencyControl/ModuleProvider.moon index 206c44f..12bdc44 100644 --- a/modules/l0/DependencyControl/ModuleProvider.moon +++ b/modules/l0/DependencyControl/ModuleProvider.moon @@ -17,14 +17,43 @@ unless state state = { providers: {}, installed: false } _G[GLOBAL_KEY] = state --- Lua module searcher: returns a loader for a registered alias, otherwise nil. +-- Runs a freshly-loaded module's DependencyControl initializer (`__depCtrlInit`) if it has one and +-- hasn't been initialized yet, so the module exposes a proper DependencyControl record. The guard +-- avoids re-initializing modules that mutate their exported state on first init (e.g. BadMutex). +runInitializer = (ref, DependencyControl) -> + return ref 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 + alreadyInitialized = type(ref.version) == "table" and ref.version.__class and + ref.version.__class.__name == DependencyControl.__name + ref.__depCtrlInit DependencyControl unless alreadyInitialized + return ref + +-- Searcher-side initializer: resolves DependencyControl from package.loaded rather than require()ing +-- it (aliases can be pulled in *during* DepCtrl's own bootstrap, where a require()-back would cycle; +-- the type check also rejects the mid-bootstrap "loading" sentinel). Until the real class is loaded +-- there's nothing to init against, so the module is returned as-is. +initProvidedModule = (mod) -> + DependencyControl = package.loaded["l0.DependencyControl"] + return mod unless type(DependencyControl) == "table" + runInitializer mod, DependencyControl + +-- 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 + -> initProvidedModule require providerName class ModuleProvider + --- Runs a freshly-loaded module reference's DependencyControl initializer (`__depCtrlInit`), if + -- it has one and hasn't been initialized yet, so the module exposes a proper DependencyControl + -- record. The guard avoids re-initializing modules that mutate state on first init (e.g. BadMutex). + -- @param ref any the loaded module reference + -- @param DependencyControl table the DependencyControl class to hand the initializer + -- @return any the same ref, for convenient chaining + @runInitializer = runInitializer + --- 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 diff --git a/modules/l0/DependencyControl/Record.moon b/modules/l0/DependencyControl/Record.moon index f25d6d5..fafc21d 100644 --- a/modules/l0/DependencyControl/Record.moon +++ b/modules/l0/DependencyControl/Record.moon @@ -11,6 +11,27 @@ ModuleProvider = require "l0.DependencyControl.ModuleProvider" SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +-- Global registry of live DepCtrl version records keyed by namespace, backed by a global table +-- so it survives DepCtrl self-update reloads. Required to reach the DepCtrl version records +-- of automation scripts/macros, which don't expose it globally (only a few script_* globals) +DEPCTRL_RECORDS_GLOBAL_KEY = "__depCtrlRecords" +recordsByNamespace = _G[DEPCTRL_RECORDS_GLOBAL_KEY] +unless recordsByNamespace + recordsByNamespace = {} + _G[DEPCTRL_RECORDS_GLOBAL_KEY] = recordsByNamespace + +--- Registers a record in the global registry under its namespace. Latest call wins. +-- @param record Record +-- @return Record the record passed in +registerRecord = (record) -> + recordsByNamespace[record.namespace] = record if record.namespace + return record + +--- Removes a namespace's record from the registry (e.g. on uninstall). +-- @param namespace string +unregisterRecord = (namespace) -> recordsByNamespace[namespace] = nil + + --- DependencyControl record representing one managed or unmanaged script/module. -- @class Record class Record extends Common @@ -45,6 +66,14 @@ class Record extends Common logDir: "?user/log", writeLogs: true} } + --- Returns the live, installed record registered for a namespace, or nil if none is registered + -- or the registered one is still a virtual (not-yet-installed) placeholder. + -- @param namespace string + -- @return Record|nil + @getRecord = (namespace) => + record = recordsByNamespace[namespace] + record unless record and record.virtual + init = => FileOps.mkdir @depConf.file, true @loadConfig! @@ -135,11 +164,13 @@ class Record extends Common @provides = [type(alias) == "table" and alias or {name: alias} for alias in *@provides] ModuleProvider\registerRecord @ - shouldWriteConfig = @loadConfig! + -- publish this record so tooling can look it up by namespace after requiring the script + registerRecord @ -- 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 + shouldWriteConfig = @loadConfig! @writeConfig if shouldWriteConfig and saveRecordToConfig checkOptionalModules: ModuleLoader.checkOptionalModules @@ -388,4 +419,7 @@ class Record extends Common -- automation scripts don't use any subdirectories if (@moduleName or mode == "file") and file\match currPattern toRemove[#toRemove+1] = path + + -- drop the record from the registry so tooling no longer sees the removed script + unregisterRecord @namespace return FileOps.remove toRemove, true, true diff --git a/modules/l0/DependencyControl/SemanticVersioning.moon b/modules/l0/DependencyControl/SemanticVersioning.moon index 1d4d765..a7bcfc1 100644 --- a/modules/l0/DependencyControl/SemanticVersioning.moon +++ b/modules/l0/DependencyControl/SemanticVersioning.moon @@ -1,3 +1,5 @@ +SemanticVersioning = nil + --- Semantic versioning utilities. -- @class SemanticVersioning class SemanticVersioning @@ -76,3 +78,19 @@ class SemanticVersioning b = bit.band b, mask return a >= b, b + + isHigher: (version, reference) -> + version, errMsg = SemanticVersioning\toNumber version + assert version, errMsg + referenceVersionNumber, errMsg = SemanticVersioning\toNumber reference + assert referenceVersionNumber, errMsg + + return version > referenceVersionNumber + + isLower: (version, reference) -> + version, errMsg = SemanticVersioning\toNumber version + assert version, errMsg + referenceVersionNumber, errMsg = SemanticVersioning\toNumber reference + assert referenceVersionNumber, errMsg + + return version < referenceVersionNumber diff --git a/modules/l0/DependencyControl/UpdateFeed.moon b/modules/l0/DependencyControl/UpdateFeed.moon index fd6037a..671b328 100644 --- a/modules/l0/DependencyControl/UpdateFeed.moon +++ b/modules/l0/DependencyControl/UpdateFeed.moon @@ -1,14 +1,18 @@ -json = require "json" +-- We ship dkjson, so depend on it directly: it guarantees the `null` sentinel, dkjson's encode +-- options, and our `indentMode: "prettier"` extension used for feed write-back. +dkjson = require "l0.dkjson" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" Enum = require "l0.DependencyControl.Enum" FileOps = require "l0.DependencyControl.FileOps" +ModuleProvider = require "l0.DependencyControl.ModuleProvider" SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" ScriptTargetFilter = require "l0.DependencyControl.ScriptTargetFilter" +JsonSchema = nil -- Iterates the real packages of a loaded feed that pass the given filter, yielding -- (pkgProxy, scriptType, section). pkgProxy exposes the package's `namespace` alongside its @@ -27,6 +31,24 @@ walkPackages = (feed, filter) -> pkgProxy = setmetatable {}, __index: (_, k) -> k == "namespace" and namespace or pkg[k] coroutine.yield pkgProxy, scriptType, section +-- Gives an expanded file record a lazily-resolved `localFilePath` property +-- by appending the file `name` to `localFileBasePath` and resolving that against the feed directory. +-- @param file table the file record to attach the accessor to +-- @param feedDirPath string the feed directory to resolve against +attachLocalFilePath = (file, feedDirPath) -> + setmetatable file, __index: (self, key) -> + return unless key == "localFilePath" + base, name = (rawget self, "localFileBasePath"), rawget self, "name" + return unless base and name + path = FileOps.validateFullPath base .. name, false, feedDirPath + return path + +-- Deep-copies a decoded feed table while dropping any field whose value is the dkjson.null +-- sentinel, turning a round-tripped JSON null back into an absent key. Used for the expanded +-- working copy so consumers see plain nil where the raw feed has an explicit null. +stripNulls = (tbl) -> + {k, (type(v) == "table" and stripNulls(v) or v) for k, v in pairs tbl when v != dkjson.null} + --- Downloaded and expanded update feed data source. -- @class UpdateFeed class UpdateFeed extends Common @@ -64,6 +86,7 @@ class UpdateFeed extends Common downloaded: "Downloaded feed to %s." } errors: { + urlOrFilePathRequired: "Either a URL or a file path must be provided." downloadAdd: "Couldn't initiate download of %s to %s (%s)." downloadFailed: "Download of feed %s to %s failed (%s)." cantOpen: "Can't open downloaded feed for reading (%s)." @@ -78,6 +101,47 @@ class UpdateFeed extends Common copied: "%s -> %s" skipped: "skipped (already exists): %s" } + ensureLoaded: { + noLocalPath: "Local expansion mode require a local feed file path to resolve local path templates against." + } + refreshFiles: { + noLocalPath: "Feed has no local path required to check file '%s' for changes." + sha1Failed: "Couldn't compute SHA-1 for file '%s' to check for changes: %s" + } + refreshVersionRecord: { + loadFailed: "Failed to load %s '%s' for getting a fresh DependencyControl version record: %s" + missingDepctrlRecord: "No DependencyControl version record exposed by %s '%s'." + } + updatePackage: { + failedRefreshVersionRecord: "Failed to refresh version/dependencies: %s" + } + update: { + notInRaw: "%s: not found in the feed data, skipping." + channelError: "%s: %s" + noRecord: "%s: no DependencyControl record (%s), skipping version/dependency refresh." + sha1Failed: " '%s': couldn't compute SHA-1 — %s" + schemaValid: "Feed conforms to schema (format v%s)." + schemaInvalid: "Feed fails schema validation (format v%s) — continuing anyway." + wrote: "Wrote %d updated package(s) to %s." + noRawData: "No raw feed data loaded — call loadFile or updateFeed first." + } + } + + -- Stable key order for serializing a feed back to JSON. Keys absent from this list are + -- appended afterwards in pairs() order (undefined, but stable for unchanged subtrees). + feedKeyOrder = { + "dependencyControlFeedFormatVersion", + "name", "description", "author", + "baseUrl", "url", "fileBaseUrl", "localFileBasePath", + "maintainer", "knownFeeds", + "moduleName", + "version", "released", "default", + "optional", + "channels", "changelog", + "files", "requiredModules", "platforms", + "sha1", "delete", "type", "platform", + "macros", "modules", + "feed", } @defaultConfig = { @@ -127,27 +191,27 @@ class UpdateFeed extends Common --- Creates an update feed wrapper and optionally fetches feed data. -- @param url string - -- @param[opt=true] autoFetch boolean + -- @param[opt=true] autoLoad boolean -- @param[opt] fileName string -- @param[opt] config table -- @param[opt] logger Logger - new: (@url, autoFetch = true, fileName, @config = {}, @logger = defaultLogger) => + new: (@_url, autoLoad = true, @fileName, @config = {}, @logger = defaultLogger) => + error msgs.errors.urlOrFilePathRequired if not @_url and not fileName + meta = getmetatable @ setmetatable @, { __index: (self, key) -> rawValue = meta[key] return rawValue if rawValue != nil - if key == 'url' then return self.fileName and "file://#{self.fileName}" or nil + if key == 'url' + return self._url if self._url + return "file://#{self.fileName}" if self.fileName } -- fill in missing config values @config[k] = v for k, v in pairs @@defaultConfig when @config[k] == nil - @fileName = fileName - if @@cache[@url] - @logger\trace msgs.trace.usingCached - @data = @@cache[@url] - elseif autoFetch - @fetch! + + @ensureLoaded! if autoLoad --- Returns URLs of all feeds referenced in the knownFeeds section of this feed. -- @return string[] urls @@ -156,11 +220,12 @@ class UpdateFeed extends Common 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 + --- Downloads feed to a temporary JSON file and sets the .fileName property for subsequent loading. + -- @param fileName? string + -- @param expansionMode? UpdateFeedExpansionMode -- @return table|boolean dataOrSuccess -- @return string|nil err - fetch: (fileName) => + fetch: (fileName, expansionMode) => -- Initialize download infrastructure lazily on first fetch. unless @downloadManager @config.downloadPath or= aegisub.decode_path "?temp/l0.#{@@__name}_feedCache" @@ -178,41 +243,76 @@ class UpdateFeed extends Common return false, msgs.errors.downloadFailed\format @url, @fileName, dl.error @logger\trace msgs.trace.downloaded, @fileName - return @loadFile @fileName + return @loadFile @fileName, expansionMode --- Loads and parses a local feed JSON file, expanding all template variables in-place. -- Use this to load a feed already on disk without going through the network. - ---@param path string Local filesystem path to the feed JSON file. - ---@param[opt] mode UpdateFeedExpansionMode expansion mode (Remote by default; Local also - -- resolves the localFileBasePath/localFilePath fields against the feed's directory). - ---@return table|boolean - ---@return string|nil err - loadFile: (path, mode = @@ExpansionMode.Remote) => - handle, err = io.open path + ---@param srcPath? string Local filesystem path to the feed JSON file. + --- Defaults to the .fileName property, which has either been provided in the + --- constructor, or set to a temporary path when the feed is fetched. + ---@param expansionMode? UpdateFeedExpansionMode expansion mode (Defaults to remote if feed is loaded + --- from an URL; otherwise local, enables resolving of the rolling @{localFileBasePath} + --- template variables to and exposes the `localFilePath` property on file records + --- for usage in build tooling such as the bundler). + ---@return table|boolean the expanded feed data, or false on failure + ---@return string|nil err error message on failure + loadFile: (srcPath = @fileName, expansionMode) => + handle, err = io.open srcPath unless handle return false, msgs.errors.cantOpen\format err - decoded, data = pcall json.decode, handle\read "*a" + -- Decode JSON null to the dkjson.null sentinel (rather than dropping it) so that + -- `released: null` and friends survive a load/write round-trip in @rawFeedData. + decoded, data = pcall dkjson.decode, handle\read("*a"), nil, dkjson.null handle\close! unless decoded and data -- luajson errors are useless dumps of whatever, no use to pass them on to the user return false, msgs.errors.parse + -- Keep the pristine decoded feed with null sentinels for write-back; + @rawFeedData = data + -- Hide null sentinels from the working copy exposed to consumers + data = stripNulls data + data[key] = {} for key in *{ @@ScriptType.name.legacy[@@ScriptType.Automation], @@ScriptType.name.legacy[@@ScriptType.Module], "knownFeeds"} when not data[key] @data, @@cache[@url] = data, data - @feedDir = path\match("^(.*)[/\\][^/\\]*$") or "." + @feedPath = srcPath + @feedDir = srcPath\match("^(.*)[/\\][^/\\]*$") or "." - @expand mode + @expand expansionMode return @data + --- Fetches the feed (or loads it from disk if local) in case it hasn't been loaded yet. + -- @param expansionMode? UpdateFeedExpansionMode the expansion mode required for the operation + -- @return table|boolean feedData the expanded feed data, or false on failure + -- @return string|nil error an error message in case of failure + ensureLoaded: (expansionMode) => + if expansionMode == @@ExpansionMode.Local and not @fileName + return nil, msgs.ensureLoaded.noLocalPath\format @url + + -- already loaded: reuse as-is when the expansion mode matches, otherwise just re-expand + if @data + return @data if not expansionMode or expansionMode == @expansionMode + return @expand expansionMode + + -- not loaded yet: fetch a remote feed by its real URL, otherwise load the local file. + if @_url + @data = @@cache[@_url] + if @data + @logger\trace msgs.trace.usingCached + return @data + return @fetch nil, expansionMode + + return @loadFile @fileName, expansionMode + --- Walks the parsed feed JSON and expands @{template} variables in-place. -- @param mode UpdateFeedExpansionMode expansion mode local mode resolves addition rolling templates for local source file paths -- @return table data - expand: (mode = @@ExpansionMode.Remote) => + expand: (mode = @expansionMode or (@_url and @@ExpansionMode.Remote or @@ExpansionMode.Local)) => {:templates, :maxDepth, :sourceAt, :rolling, :sourceKeys} = templateData - localMode = mode == @@ExpansionMode.Local + isLocalMode = mode == @@ExpansionMode.Local vars, rvars = {}, {i, {} for i=0, maxDepth} expandTemplates = (val, depth, rOff=0) -> @@ -248,6 +348,9 @@ class UpdateFeed extends Common rvars[depth][name] = expandTemplates rvars[depth][name], depth, -1 obj[templates[name].key] = rvars[depth][name] + -- file records (array entries under a `files` key) get a lazy localFilePath accessor + attachLocalFilePath obj, @feedDir if isLocalMode and upKey == "files" + -- expand variables in non-template strings and recurse tables for k,v in pairs obj if sourceKeys[k] ~= depth and not rolling[k] @@ -261,10 +364,11 @@ class UpdateFeed extends Common rvars[depth+1] = {} recurse @data + @expansionMode = mode if @dumpExpanded handle = io.open @fileName\gsub(".json$", ".exp.json"), "w" - handle\write(json.encode @data)\close! + handle\write(dkjson.encode @data, indentMode: "prettier")\close! return @data @@ -282,9 +386,8 @@ class UpdateFeed extends Common 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 + return nil, msgs.errors.invalidScriptType\format scriptType, + table.concat ["#{v} (#{Common.ScriptType.name.canonical[v]})" for k, v in pairs Common.ScriptType when k != "name"], ", " scriptData = @data[section][namespace] return false unless scriptData @@ -321,6 +424,231 @@ class UpdateFeed extends Common return ch.version if ch.default fallback + --- Resolves which channel of a package to operate on. + -- With an explicit name, that channel must exist; otherwise the channel flagged `default: true` + -- is used. + -- @param channels table the package's `channels` map + -- @param channelName? string an explicit channel name to select + -- @return string|nil name the resolved channel name, or nil if none matched + -- @return string|nil err Error message on failure + @resolveChannel = (channels = {}, channelName) => + if channelName + return channelName if channels[channelName] + return nil, "channel '#{channelName}' not found" + for name, channel in pairs channels + return name if channel.default + return nil, "no default channel — specify one explicitly" + + --- Writes the raw (unexpanded) feed data back to disk. + -- @param path? string destination path (defaults to the source path of the loaded feed) + -- @return boolean success Whether the write succeeded + -- @return string|nil err Error message on failure + writeRawFeed: (path) => + loaded, err = @ensureLoaded! + return false, err unless loaded + path or= @feedPath + encoded = dkjson.encode @rawFeedData, {indentMode: "prettier", keyorder: feedKeyOrder} + FileOps.writeFile path, "#{encoded}\n", true + + --- Validates @rawFeedData against the feed schema matching its declared format version. + -- Best-effort: warns through @logger but never raises, so an unavailable schema rock or a + -- non-conforming feed doesn't block an update. + -- @param schemaDir string|string[] directory holding the feed schemas (named `v.json`) + -- @return boolean|nil valid Whether the feed is valid, or nil if validation couldn't be performed + -- @return string schemaVersionOrErrMsg The feed format version the feed validated against, + -- or an error message if validation couldn't be performed. + validateAgainstSchema: (schemaDir) => + JsonSchema or= require "l0.DependencyControl.JsonSchema" + + schemaPathsByVersion, schemasErr = JsonSchema\getSchemasInDirectory schemaDir + unless schemaPathsByVersion + return nil, nil, schemasErr + + -- strip dkjson null sentinels before validation as lua-schema trips over them + validationData = stripNulls @rawFeedData + isValid, validationVersion, validationErr = JsonSchema\validateAny validationData, + schemaPathsByVersion, @rawFeedData.dependencyControlFeedFormatVersion + + if isValid + return true, validationVersion, msgs.update.schemaValid\format validationVersion + return isValid, validationVersion, validationErr + + --- Updates a package channel's version and dependencies in the raw feed data by loading + -- the package's script and reading its DependencyControl record. + -- @param scriptType number the script type of the package to refresh (supported: Common.ScriptType.Automation or Common.ScriptType.Module) + -- @param packageNamespace string the package namespace + -- @param rawChannel table the raw channel entry to update in place + -- @return boolean|nil changed whether anything was modified or nil on error + -- @return string|nil err error message on failure + refreshVersionRecord: (scriptType, packageNamespace, rawChannel) => + -- Require the script so it registers its DependencyControl record by namespace: macros do + -- so simply by running, modules by constructing their record at load. Modules that defer to + -- a lazy __depCtrlInit (e.g. dkjson) are initialized explicitly below. The record is then + -- looked up from the registry — the only place a macro's record (and its deps) is reachable. + DependencyControl = require Common.moduleName + success, mod = xpcall require, debug.traceback, packageNamespace + ModuleProvider.runInitializer mod, DependencyControl if success + + record = DependencyControl\getRecord packageNamespace + unless record + return nil, success and msgs.refreshVersionRecord.missingDepctrlRecord\format(scriptType, packageNamespace) or + msgs.refreshVersionRecord.loadFailed\format scriptType, packageNamespace, mod + + changed = false + newVer, verErr = SemanticVersioning\toString record.version + return nil, verErr unless newVer + if newVer != rawChannel.version + rawChannel.version = newVer + changed = true + + existingDepsByName = {dep.moduleName, dep for dep in *rawChannel.requiredModules or {}} + newDeps = {} + for dep in *record.requiredModules or {} + existing = existingDepsByName[dep.moduleName] + entry = moduleName: dep.moduleName + entry.version = dep.version if dep.version != nil + entry.optional = dep.optional if dep.optional != nil + if existing + entry.feed = existing.feed if existing.feed != nil + entry.url = existing.url if existing.url != nil + entry.name = existing.name if existing.name != nil + else + entry.feed = dep.feed if dep.feed != nil + entry.url = dep.url if dep.url != nil + entry.name = dep.name if dep.name != nil + newDeps[#newDeps + 1] = entry + + -- Compare only the semantically relevant fields, ignoring order: a moduleName-keyed + -- digest of each dep's version/optional. Template fields (feed/url/name) are carried over + -- verbatim, so they never count as a change on their own. version/optional are normalized + -- (absent version == "", absent/false optional == false) so that purely representational + -- differences don't register as changes. + getDepSignature = (deps) -> + Common.getObjectHash {d.moduleName, {version: d.version or "", optional: d.optional and true or false} for d in *deps or {}} + if getDepSignature(newDeps) != getDepSignature rawChannel.requiredModules + rawChannel.requiredModules = #newDeps > 0 and newDeps or nil + changed = true + + return changed + + --- Refreshes the SHA-1 hashes of a channel's files from their local sources and flags any + -- file that has vanished locally with `delete: true` so the Updater removes it from users' + -- installations on their next update. Files already flagged for deletion are left untouched. + -- @param rawChannel table the raw channel entry to update in place + -- @param expandedChannel table the matching expanded channel + -- @return boolean changed whether anything was modified + -- @return string[] errors per-file error messages encountered while refreshing + refreshFiles: (rawChannel, expandedChannel) => + return false, {} unless rawChannel.files + + changed, errors = false, {} + for i, rawFile in ipairs rawChannel.files + expFile = expandedChannel and expandedChannel.files and expandedChannel.files[i] + localPath = expFile and expFile.localFilePath + continue if rawFile.delete + if not localPath + errors[#errors + 1] = msgs.refreshFiles.noLocalPath\format rawFile.name + elseif FileOps.exists localPath, "file" + newHash, err = FileOps.getHash localPath + unless newHash + errors[#errors + 1] = msgs.refreshFiles.sha1Failed\format rawFile.name, tostring err + else if newHash\upper! != (rawFile.sha1 or "")\upper! + rawFile.sha1 = newHash\upper! + changed = true + else + rawFile.delete = true + changed = true + + return changed, errors + + --- Applies all in-place updates to a single package's selected channel and, if anything + -- changed, resets its `released` date to null to mark the build as pending/unreleased. + -- Collects this package's own outcome rather than mutating shared state, so the caller can + -- present results per package. + -- @param scriptType Common.ScriptType the package's script type (1 for automation or 2 for module) + -- @param packageNamespace string the namespaced identifier of the package to update (e.g. "l0.Functional") + -- @param channel? string the channel to update (default: each package's default channel) + -- @return table result { namespace, scriptType, channel?, changed = boolean, errors = string[] } + updatePackage: (scriptType, packageNamespace, channel) => + result = {namespace: packageNamespace, :scriptType, changed: false, errors: {}} + errors = result.errors + + section = Common.ScriptType.name.legacy[scriptType] + + rawPkg = @rawFeedData[section] and @rawFeedData[section][packageNamespace] + unless rawPkg + errors[#errors + 1] = msgs.update.notInRaw\format packageNamespace + return result + + channelName, err = @@resolveChannel rawPkg.channels, channel + unless channelName + errors[#errors + 1] = msgs.update.channelError\format packageNamespace, err + return result + result.channel = channelName + + rawChannel = rawPkg.channels[channelName] + expandedSection = @data[section] and @data[section][packageNamespace] + expandedChannel = expandedSection and expandedSection.channels[channelName] + + depsChanged, depErr = @refreshVersionRecord scriptType, packageNamespace, rawChannel + errors[#errors + 1] = msgs.updatePackage.failedRefreshVersionRecord\format depErr if depErr + + filesChanged, fileErrors = @refreshFiles rawChannel, expandedChannel + errors[#errors + 1] = e for e in *fileErrors + + if depsChanged or filesChanged + rawChannel.released = dkjson.null + result.changed = true + + return result + + --- Loads the feed (unless already loaded), optionally validates it, refreshes the targeted + -- packages in place and writes the result back to disk. The feed path is the one supplied to + -- the constructor; pre-load with @{loadFile} if you need to act on the feed before refresh. + -- @param opts? table options to customize the behavior; fields: + -- channel? string channel to update (default: each package's default channel) + -- filter? ScriptTargetFilter restricts which packages are processed (default: all) + -- schemaDir? string|string[] directory of feed schemas (`v.json`); when given, + -- the feed is validated against its declared format version. + -- outPath? string|boolean where to write the updated feed. `false` performs a dry run, + -- defaults to the source path of the loaded feed. + -- @return table|nil stats { changed = number (packages changed), errored = number (packages with + -- errors), packages = { {namespace, scriptType, channel?, changed, errors}, ... } }, or + -- nil on a fatal load/write error + -- @return string|nil err + updateFeed: (opts = {}) => + -- Loads lazily in Local mode; a prior walkFiles/walkPackages (e.g. from registering a + -- module searcher) may already have loaded the feed, in which case this is a no-op. + loaded, err = @ensureLoaded @@ExpansionMode.Local + return nil, err unless loaded + + dryRun = opts.outPath == false + outPath = (opts.outPath == true or opts.outPath == nil) and @feedPath or opts.outPath + + if opts.schemaDir + schemaValid, _, schemaMsg = @validateAgainstSchema opts.schemaDir + if schemaValid + @logger\trace schemaMsg if schemaMsg + elseif schemaMsg + @logger\warn schemaMsg + + filter = opts.filter or ScriptTargetFilter!\includeAll! + stats = changed: 0, errored: 0, packages: {} + for pkg, scriptType in @walkPackages filter + -- isolate per-package processing so one package's failure doesn't abort the whole run + ok, result = pcall @updatePackage, @, scriptType, pkg.namespace, opts.channel + result = {namespace: pkg.namespace, :scriptType, changed: false, errors: {tostring result}} unless ok + stats.packages[#stats.packages + 1] = result + stats.changed += 1 if result.changed + stats.errored += 1 if #result.errors > 0 + + if stats.changed > 0 and not dryRun + wrote, writeErr = @writeRawFeed outPath + return nil, writeErr unless wrote + @logger\hint msgs.update.wrote, stats.changed, outPath + + return stats + --- Copies every file listed in the feed to distDir using the Updater's install layout. -- The feed must have been loaded with ExpansionMode.Local so localFileBasePath is populated. -- @param distDir string absolute path of the output dist directory @@ -375,6 +703,7 @@ class UpdateFeed extends Common -- @param filter? ScriptTargetFilter restricts which packages are walked (default: all) -- @return function iterator walkPackages: (filter = ScriptTargetFilter!\includeAll!) => + @ensureLoaded! walkPackages @, filter --- Returns a coroutine-based iterator over every file entry of the packages passing the filter. @@ -388,18 +717,13 @@ class UpdateFeed extends Common -- @param filter? ScriptTargetFilter restricts which packages are walked (default: all) -- @return function iterator walkFiles: (filter = ScriptTargetFilter!\includeAll!) => - FileOps = require "l0.DependencyControl.FileOps" - feedDir = @feedDir - + @ensureLoaded @@ExpansionMode.Local coroutine.wrap -> for pkg, scriptType, section in walkPackages @, filter for channelName, channel in pairs pkg.channels or {} chanProxy = setmetatable {}, __index: (_, k) -> k == "name" and channelName or channel[k] + -- file records carry their own lazy `.localFilePath` (attached during local-mode + -- expansion), so they can be yielded directly without a wrapping proxy. for file in *channel.files or {} - fileProxy = setmetatable {}, { - __index: (_, k) -> - return (FileOps.validateFullPath file.localFileBasePath .. file.name, false, feedDir) if k == "localFilePath" - file[k] - } - coroutine.yield fileProxy, chanProxy, pkg, section, scriptType + coroutine.yield file, chanProxy, pkg, section, scriptType diff --git a/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon index e6cddb7..792c110 100644 --- a/modules/l0/DependencyControl/test.moon +++ b/modules/l0/DependencyControl/test.moon @@ -191,41 +191,7 @@ 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" - } - } + ModuleProvider: (controls\requireTest "ModuleProvider") basePath, DepCtrl Downloader: { _description: "Tests for the Downloader engine: round-robin scheduling and per-download callbacks (via a fake driver). (Offline — no network.)" @@ -1975,6 +1941,33 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> ut\assertContains registered[1][1], "MyMacro" registerTestsStub\assertCalledOnceWith rec + -- namespace registry: getRecord is the public lookup; registration happens internally (via + -- the constructor), so these seed the process-global registry directly, with unique namespaces. + + registry_getReturnsRegistered: (ut) -> + ns = uniqueName "regns" + rec = {namespace: ns} + _G.__depCtrlRecords[ns] = rec + ut\assertIs Record\getRecord(ns), rec + + registry_getMissing: (ut) -> + ut\assertNil Record\getRecord uniqueName "absent" + + registry_getSkipsVirtual: (ut) -> + ns = uniqueName "virtns" + _G.__depCtrlRecords[ns] = {namespace: ns, virtual: true} + ut\assertNil Record\getRecord ns + + -- a virtual placeholder flipped to non-virtual in place (as the Updater does on install) + -- becomes visible through getRecord + registry_returnsAfterUnvirtualized: (ut) -> + ns = uniqueName "virtns" + rec = {namespace: ns, virtual: true} + _G.__depCtrlRecords[ns] = rec + ut\assertNil Record\getRecord ns + rec.virtual = false + ut\assertIs Record\getRecord(ns), rec + _order: { "checkVersion_equal", "checkVersion_greater", "checkVersion_older", "checkVersion_recordArg", "setVersion_validString", "setVersion_validNumber", "setVersion_invalid", @@ -1982,7 +1975,9 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> "validateNamespace_invalid_trailingDot", "validateNamespace_virtual", "uninstall_virtual", "uninstall_unmanaged", "getSubmodules_virtual", "getSubmodules_unmanaged", "getSubmodules_nonModule", - "getConfigFileName_basic", "registerMacro_basic" + "getConfigFileName_basic", "registerMacro_basic", + "registry_getReturnsRegistered", "registry_getMissing", + "registry_getSkipsVirtual", "registry_returnsAfterUnvirtualized" } } @@ -2152,6 +2147,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> ScriptTargetFilter: (controls\requireTest "ScriptTargetFilter")! + JsonSchema: (controls\requireTest "JsonSchema") basePath + DownloaderIntegration: { _description: "Real-HTTP Downloader tests against a local test server (runs when launchable)." diff --git a/modules/l0/DependencyControl/test/Common.moon b/modules/l0/DependencyControl/test/Common.moon index 099a463..d5511bc 100644 --- a/modules/l0/DependencyControl/test/Common.moon +++ b/modules/l0/DependencyControl/test/Common.moon @@ -89,11 +89,39 @@ ut\assertEquals flat[2], "b" ut\assertEquals flat[3], "x" + -- getObjectHash: deterministic, order-independent SHA-1 of a (nested) value + + getObjectHash_isHexString: (ut) -> + hash = Common.getObjectHash {a: 1, b: "two"} + ut\assertString hash + ut\assertMatches hash, "^%x+$" + + getObjectHash_deterministic: (ut) -> + ut\assertEquals Common.getObjectHash({a: 1, b: 2}), Common.getObjectHash {a: 1, b: 2} + + getObjectHash_ignoresKeyOrder: (ut) -> + ut\assertEquals Common.getObjectHash({a: 1, b: 2, c: 3}), Common.getObjectHash {c: 3, a: 1, b: 2} + + getObjectHash_nestedOrderIndependent: (ut) -> + a = {x: {p: 1, q: 2}, y: 3} + b = {y: 3, x: {q: 2, p: 1}} + ut\assertEquals Common.getObjectHash(a), Common.getObjectHash b + + getObjectHash_distinguishesContent: (ut) -> + ut\assertNotEquals Common.getObjectHash({v: "1"}), Common.getObjectHash {v: "2"} + + -- type tagging keeps the number 1 and the string "1" from colliding + getObjectHash_typeTagged: (ut) -> + ut\assertNotEquals Common.getObjectHash({v: 1}), Common.getObjectHash {v: "1"} + _order: { "getAutomationDir_automation", "getAutomationDir_module", "getAutomationDir_customRoot", "getAutomationDir_unknown", "getTestDir_automation", "getTestDir_module", "getTestDir_customRoot", "flatten_depth2Array", "flatten_depth1StopsEarly", "flatten_depth0NoFlatten", - "flatten_scalar", "flatten_returnsCount", "flatten_toArrayTable" + "flatten_scalar", "flatten_returnsCount", "flatten_toArrayTable", + "getObjectHash_isHexString", "getObjectHash_deterministic", "getObjectHash_ignoresKeyOrder", + "getObjectHash_nestedOrderIndependent", "getObjectHash_distinguishesContent", + "getObjectHash_typeTagged" } } diff --git a/modules/l0/DependencyControl/test/JsonSchema.moon b/modules/l0/DependencyControl/test/JsonSchema.moon new file mode 100644 index 0000000..1c05879 --- /dev/null +++ b/modules/l0/DependencyControl/test/JsonSchema.moon @@ -0,0 +1,71 @@ +-- JsonSchema tests: schema discovery and multi-version validation orchestration. These avoid the +-- actual lua-schema dependency by stubbing FileOps and passing pre-built (mock) schema instances. +-- Called from test.moon as: (require "...test.JsonSchema") basePath +(basePath) -> + JsonSchema = require "l0.DependencyControl.JsonSchema" + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + + -- a stand-in for a JsonSchema instance with a scripted validate(data) result + mockSchema = (valid, err) -> {__class: JsonSchema, validate: (self, data) -> valid, err} + + { + _description: "Tests for JsonSchema: getSchemasInDirectory discovery and validateAny orchestration." + + -- getSchemasInDirectory + + getSchemasInDirectory_mapsVersionsToPaths: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "listDir")\returns {"v0.3.0.json", "v0.4.0.json", "readme.txt"} + (ut\stub FILEOPS_MODULE_NAME, "joinPath")\calls (dir, name) -> "#{dir}/#{name}" + result = JsonSchema\getSchemasInDirectory "/schemas" + ut\assertTable result + ut\assertContains result["0.4.0"], "v0.4.0.json" + ut\assertContains result["0.3.0"], "v0.3.0.json" + ut\assertNil result["readme"] + + getSchemasInDirectory_noneFound: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "listDir")\returns {"readme.txt", "notes.md"} + result, err = JsonSchema\getSchemasInDirectory "/schemas" + ut\assertNil result + ut\assertString err + + getSchemasInDirectory_dirReadError: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "listDir")\returns nil, "permission denied" + result, err = JsonSchema\getSchemasInDirectory "/schemas" + ut\assertNil result + ut\assertContains err, "permission denied" + + -- validateAny + + validateAny_exactVersionValid: (ut) -> + isValid, version = JsonSchema\validateAny {}, {"0.4.0": mockSchema true}, "0.4.0" + ut\assertTrue isValid + ut\assertEquals version, "0.4.0" + + validateAny_reportsInvalidWithError: (ut) -> + isValid, version, err = JsonSchema\validateAny {}, {"0.4.0": mockSchema(false, "name must be string")}, "0.4.0" + ut\assertFalse isValid + ut\assertEquals version, "0.4.0" + ut\assertContains err, "name must be string" + + -- with no schema for the feed's declared version, it falls through to the available ones + validateAny_fallsThroughToOtherVersions: (ut) -> + isValid, version = JsonSchema\validateAny {}, {"0.3.0": mockSchema true}, "0.4.0" + ut\assertTrue isValid + ut\assertEquals version, "0.3.0" + + -- with no schema matching and the others all rejecting, the result is nil with errors aggregated + -- (a false from the exact declared version is instead returned verbatim — that's a definitive no) + validateAny_aggregatesAllFailures: (ut) -> + schemas = {"0.3.0": mockSchema(false, "err A"), "0.2.0": mockSchema(false, "err B")} + isValid, _, err = JsonSchema\validateAny {}, schemas, "0.4.0" -- 0.4.0 absent -> falls through + ut\assertNil isValid + ut\assertContains err, "err A" + ut\assertContains err, "err B" + + _order: { + "getSchemasInDirectory_mapsVersionsToPaths", "getSchemasInDirectory_noneFound", + "getSchemasInDirectory_dirReadError", + "validateAny_exactVersionValid", "validateAny_reportsInvalidWithError", + "validateAny_fallsThroughToOtherVersions", "validateAny_aggregatesAllFailures" + } + } diff --git a/modules/l0/DependencyControl/test/ModuleProvider.moon b/modules/l0/DependencyControl/test/ModuleProvider.moon new file mode 100644 index 0000000..247b5f7 --- /dev/null +++ b/modules/l0/DependencyControl/test/ModuleProvider.moon @@ -0,0 +1,67 @@ +-- ModuleProvider tests: alias registration, searcher-based resolution, and the shared +-- __depCtrlInit runner. Called from test.moon as: (require "...test.ModuleProvider") basePath, DepCtrl +-- (Names are unique per run since the provider registry is process-global.) +(basePath, DepCtrl) -> + ModuleProvider = require "l0.DependencyControl.ModuleProvider" + SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + uniqueName = (prefix) -> "#{prefix}_#{'%08X'\format math.random 0, 16^8-1}" + + { + _description: "Tests for ModuleProvider: alias registration, searcher resolution, and the shared __depCtrlInit runner." + + 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 + + -- runInitializer: shared __depCtrlInit guard + call (also used by ModuleLoader & UpdateFeed) + + -- a plain module with no initializer is returned untouched + runInitializer_noInitHook: (ut) -> + ref = {version: "1.0.0"} + ut\assertIs ModuleProvider.runInitializer(ref, {__name: "DependencyControl"}), ref + + -- an uninitialized module (raw .version) gets its initializer run with the DepCtrl class + runInitializer_runsWhenUninitialized: (ut) -> + fakeDC, received = {__name: "DependencyControl"}, {} + ref = {version: "raw-version-string", __depCtrlInit: (dc) -> received[#received + 1] = dc} + ModuleProvider.runInitializer ref, fakeDC + ut\assertEquals #received, 1 + ut\assertIs received[1], fakeDC + + -- a module whose .version is already a DepCtrl record must NOT be re-initialized + runInitializer_skipsWhenInitialized: (ut) -> + fakeDC, calls = {__name: "DependencyControl"}, 0 + ref = {version: {__class: {__name: "DependencyControl"}}, __depCtrlInit: -> calls += 1} + ModuleProvider.runInitializer ref, fakeDC + ut\assertEquals calls, 0 + + _order: { + "register_andGetProvider", "register_firstWins", + "registerRecord_normalizesAliases", "searcher_resolvesAliasToProvider", + "runInitializer_noInitHook", "runInitializer_runsWhenUninitialized", + "runInitializer_skipsWhenInitialized" + } + } diff --git a/modules/l0/DependencyControl/test/UpdateFeed.moon b/modules/l0/DependencyControl/test/UpdateFeed.moon index 6ce3d1b..ae2471c 100644 --- a/modules/l0/DependencyControl/test/UpdateFeed.moon +++ b/modules/l0/DependencyControl/test/UpdateFeed.moon @@ -59,6 +59,8 @@ feedDir: basePath, __class: UpdateFeed } + -- walkFiles lazily loads via ensureLoaded; stub it away since data is supplied directly + ensureLoadedStub = (ut\stub feed, "ensureLoaded")\calls (self) -> self.data results = {} for file, channel, pkg, section, scriptType in UpdateFeed.walkFiles(feed) results[#results + 1] = {:file, :channel, :pkg, :section, :scriptType} @@ -68,17 +70,21 @@ ut\assertEquals results[1].file.name, "NS.moon" ut\assertEquals results[1].section, "modules" ut\assertEquals results[1].scriptType, Common.ScriptType.Module + ensureLoadedStub\assertCalledOnce! - walkFiles_localFilePath: (ut) -> + -- walkFiles yields files untouched; the localFilePath accessor itself is attached by `expand` + -- (covered by expand_attachesLocalFilePath), so here it's supplied directly on the file record. + walkFiles_passesThroughLocalFilePath: (ut) -> feed = { data: { modules: {"test.NS": {channels: {release: {version: "1.0.0", - files: {{name: "NS.moon", localFileBasePath: "./"}}}}}}, + files: {{name: "NS.moon", localFilePath: FileOps.joinPath(basePath, "NS.moon")}}}}}}, macros: {} }, feedDir: basePath, __class: UpdateFeed } + (ut\stub feed, "ensureLoaded")\calls (self) -> self.data for file in UpdateFeed.walkFiles(feed) ut\assertString file.localFilePath ut\assertContains file.localFilePath, "NS.moon" @@ -155,11 +161,109 @@ ut\assertEquals fileCount, 0 ut\assertEquals errCount, 1 + -- ensureLoaded + + ensureLoaded_localWithoutFileName_errors: (ut) -> + result, err = UpdateFeed.ensureLoaded {__class: UpdateFeed}, UpdateFeed.ExpansionMode.Local + ut\assertNil result + ut\assertString err + + ensureLoaded_reusesMatchingExpansion: (ut) -> + data = {modules: {}} + feed = {data: data, expansionMode: UpdateFeed.ExpansionMode.Local, fileName: "x.json", __class: UpdateFeed} + ut\assertIs UpdateFeed.ensureLoaded(feed, UpdateFeed.ExpansionMode.Local), data + + -- refreshFiles: returns (changed, errors) and mutates the raw channel in place + + refreshFiles_updatesChangedSha: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "exists")\returns true + (ut\stub FILEOPS_MODULE_NAME, "getHash")\returns "deadbeef" + rawChannel = {files: {{name: "a.moon", sha1: "OLDHASH"}}} + expandedChannel = {files: {{localFilePath: "/x/a.moon"}}} + changed, errors = UpdateFeed.refreshFiles {__class: UpdateFeed}, rawChannel, expandedChannel + ut\assertTrue changed + ut\assertEquals #errors, 0 + ut\assertEquals rawChannel.files[1].sha1, "DEADBEEF" + + refreshFiles_unchangedSha: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "exists")\returns true + (ut\stub FILEOPS_MODULE_NAME, "getHash")\returns "abc123" + rawChannel = {files: {{name: "a.moon", sha1: "ABC123"}}} + changed, errors = UpdateFeed.refreshFiles {__class: UpdateFeed}, rawChannel, {files: {{localFilePath: "/x/a.moon"}}} + ut\assertFalse changed + ut\assertEquals #errors, 0 + + refreshFiles_missingFileFlagsDelete: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "exists")\returns false -- vanished from disk + rawChannel = {files: {{name: "gone.moon", sha1: "X"}}} + changed, errors = UpdateFeed.refreshFiles {__class: UpdateFeed}, rawChannel, {files: {{localFilePath: "/x/gone.moon"}}} + ut\assertTrue changed + ut\assertTrue rawChannel.files[1].delete + ut\assertEquals #errors, 0 + + refreshFiles_sha1FailureCollectsError: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "exists")\returns true + (ut\stub FILEOPS_MODULE_NAME, "getHash")\returns nil, "boom" + rawChannel = {files: {{name: "a.moon", sha1: "X"}}} + changed, errors = UpdateFeed.refreshFiles {__class: UpdateFeed}, rawChannel, {files: {{localFilePath: "/x/a.moon"}}} + ut\assertFalse changed + ut\assertEquals #errors, 1 + ut\assertContains errors[1], "a.moon" + + refreshFiles_noLocalPathCollectsError: (ut) -> + rawChannel = {files: {{name: "a.moon", sha1: "X"}}} + changed, errors = UpdateFeed.refreshFiles {__class: UpdateFeed}, rawChannel, {files: {{}}} + ut\assertFalse changed + ut\assertEquals #errors, 1 + + -- updatePackage: returns a per-package result rather than mutating shared state + + updatePackage_notInRaw: (ut) -> + feed = {rawFeedData: {modules: {}}, data: {modules: {}}, __class: UpdateFeed} + result = UpdateFeed.updatePackage feed, Common.ScriptType.Module, "no.Such", nil + ut\assertFalse result.changed + ut\assertEquals #result.errors, 1 + ut\assertContains result.errors[1], "no.Such" + + updatePackage_collectsResultAndResetsReleased: (ut) -> + ns = "test.NS" + feed = { + rawFeedData: {modules: {[ns]: {channels: {release: {default: true, released: "2024-01-01", files: {}}}}}}, + data: {modules: {[ns]: {channels: {release: {files: {}}}}}}, + __class: UpdateFeed + } + (ut\stub feed, "refreshVersionRecord")\returns true -- version/deps changed + (ut\stub feed, "refreshFiles")\returns false, {} + result = UpdateFeed.updatePackage feed, Common.ScriptType.Module, ns, nil + ut\assertEquals result.namespace, ns + ut\assertEquals result.channel, "release" + ut\assertTrue result.changed + ut\assertEquals #result.errors, 0 + ut\assertNotNil feed.rawFeedData.modules[ns].channels.release.released -- reset to null sentinel + + updatePackage_collectsRefreshError: (ut) -> + ns = "test.NS" + feed = { + rawFeedData: {modules: {[ns]: {channels: {release: {default: true, files: {}}}}}}, + data: {modules: {[ns]: {channels: {release: {files: {}}}}}}, + __class: UpdateFeed + } + (ut\stub feed, "refreshVersionRecord")\returns nil, "no record" + (ut\stub feed, "refreshFiles")\returns false, {} + result = UpdateFeed.updatePackage feed, Common.ScriptType.Module, ns, nil + ut\assertEquals #result.errors, 1 + ut\assertContains result.errors[1], "no record" + _order: { "getModuleVersion_defaultChannel", "getModuleVersion_fallback", "getModuleVersion_missing", "getFileDeployPath_module", "getFileDeployPath_test", - "walkFiles_yieldsProxies", "walkFiles_localFilePath", + "walkFiles_yieldsProxies", "walkFiles_passesThroughLocalFilePath", "deployFiles_copiesToDist", "deployFiles_skipExistingNoClobber", - "deployFiles_countsMissingSource" + "deployFiles_countsMissingSource", + "ensureLoaded_localWithoutFileName_errors", "ensureLoaded_reusesMatchingExpansion", + "refreshFiles_updatesChangedSha", "refreshFiles_unchangedSha", "refreshFiles_missingFileFlagsDelete", + "refreshFiles_sha1FailureCollectsError", "refreshFiles_noLocalPathCollectsError", + "updatePackage_notInRaw", "updatePackage_collectsResultAndResetsReleased", + "updatePackage_collectsRefreshError" } } diff --git a/modules/l0/dkjson.moon b/modules/l0/dkjson.moon index cf276aa..ad87a94 100644 --- a/modules/l0/dkjson.moon +++ b/modules/l0/dkjson.moon @@ -2,17 +2,117 @@ -- -- The upstream library is kept pristine and unmodified at `modules/l0/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. +-- carries a DependencyControl version record, adds a Prettier-flavored `indentMode` encode option, +-- and defers everything else to the upstream module. -- --- Resolving the bare module specifiers this module `provides` ("json", "dkjson") is +-- 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" +DEFAULT_PRETTIER_PRINT_WIDTH = 80 + +-- Serializes a Lua value to Prettier-flavored JSON: two-space indents, a space after every colon, +-- one property per line for objects, and arrays kept on a single line when they fit within +-- the print width (otherwise one element per line). Object keys listed in `state.keyorder` are +-- emitted first in that order; any remaining keys follow `state.defaultKeyOrder` (case-insensitive +-- alphabetical by default), so the output is fully deterministic. Scalars and the null sentinel are +-- delegated to upstream dkjson for correct escaping. +prettyEncode = (value, state = {}) -> + keyorder = state.keyorder or {} + printWidth = state.indentPrintWidth or DEFAULT_PRETTIER_PRINT_WIDTH + defaultKeySorter = (a, b) -> string.lower(tostring a) < string.lower tostring b + defaultKeySorter = state.defaultKeyOrder if type(state.defaultKeyOrder) == "function" + + rank = {k, i for i, k in ipairs keyorder} + indentStr = (level) -> (" ")\rep level + + -- Classifies a table as a JSON "array" or "object", honoring dkjson's decode-time __jsontype + -- tag and otherwise falling back to a key-shape heuristic (empty tables become objects). + classify = (tbl, meta) -> + return meta.__jsontype if meta and meta.__jsontype + len, count = #tbl, 0 + count += 1 for _ in pairs tbl + return len > 0 and len == count and "array" or "object" + + -- Object keys ordered by `keyorder` rank first, then alphabetically. + orderedKeys = (tbl) -> + keys = [k for k in pairs tbl] + table.sort keys, (a, b) -> + ra, rb = rank[a], rank[b] + return ra < rb if ra and rb + return ra != nil if ra or rb + defaultKeySorter a, b + keys + + local compact, forcesBreak, render + + -- Single-line rendering, used only to measure whether an array fits on the current line. + compact = (val) -> + meta = getmetatable val + return dkjson.encode val if type(val) != "table" or (meta and meta.__tojson) + if classify(val, meta) == "array" + "[#{table.concat [compact v for v in *val], ", "}]" + else + "{#{table.concat ["#{dkjson.encode k}: #{compact val[k]}" for k in *orderedKeys val], ", "}}" + + -- Whether a value must span multiple lines regardless of width: non-empty objects always break, + -- and an array breaks if any of its elements does. + forcesBreak = (val) -> + meta = getmetatable val + return false if type(val) != "table" or (meta and meta.__tojson) + if classify(val, meta) == "array" + for v in *val + return true if forcesBreak v + false + else next(val) != nil + + -- Full rendering. `col` is the column the value begins at, used to decide whether an array + -- still fits on the current line. + render = (val, col, level) -> + meta = getmetatable val + return dkjson.encode val if type(val) != "table" or (meta and meta.__tojson) + + if classify(val, meta) == "array" + return "[]" if #val == 0 + unless forcesBreak val + inline = compact val + return inline if col + #inline <= printWidth + inner = indentStr level + 1 + "[\n#{table.concat ["#{inner}#{render v, (level + 1) * 2, level + 1}" for v in *val], ",\n"}\n#{indentStr level}]" + else + keys = orderedKeys val + return "{}" if #keys == 0 + inner = indentStr level + 1 + parts = for k in *keys + key = dkjson.encode k + "#{inner}#{key}: #{render val[k], #inner + #key + 2, level + 1}" + "{\n#{table.concat parts, ",\n"}\n#{indentStr level}}" + + render value, 0, 0 + wrapper = setmetatable {}, __index: dkjson +--- Encodes a Lua value as JSON. +-- The DependencyControl-bundled package adds the following state options on top of upstream dkjson: +-- - `state.indentMode`: when set to 'prettier', formatting matches Prettier (two-space indents, a space +-- after each colon, objects one-property-per-line, arrays collapsed when they fit within the configured +-- print width). +-- - `state.indentPrintWidth`: the target line width for the 'prettier' indent mode (default: 80). +-- - `state.defaultKeyOrder`: a function that accepts two keys and returns true if the first should appear +-- before the second when encoding objects, and false otherwise. This is used to sort object keys +-- that do not appear in `state.keyorder` (which takes precedence). Default is case-insensitive alphabetical. +-- Currently only used in the 'prettier' indent mode. +-- returning the encoded string. Any other `indentMode` (or none) defers entirely to upstream dkjson. +-- @param value any the value to encode +-- @param state? table dkjson encode state, optionally carrying `indentMode`/`keyorder` +-- @return string|boolean the JSON string, or dkjson's native return value for non-prettier modes +wrapper.encode = (value, state) -> + return prettyEncode value, state if state and state.indentMode == "prettier" + return dkjson.encode value, state + wrapper.__depCtrlInit = (DependencyControl) -> wrapper.version = DependencyControl { name: "dkjson" diff --git a/schemas/feed/v0.4.0.json b/schemas/feed/v0.4.0.json index 49afc9d..e4c91e7 100644 --- a/schemas/feed/v0.4.0.json +++ b/schemas/feed/v0.4.0.json @@ -66,7 +66,8 @@ "NamespacedIdentifier": { "description": "A dot-separated namespaced identifier (e.g. 'l0.DependencyControl.Toolbox', 'a-mo.LineCollection').", "type": "string", - "pattern": "^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+$" + "pattern": "^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+$", + "lpegPattern": "[A-Za-z0-9_-]+ ('.' [A-Za-z0-9_-]+)+ !." }, "Changelog": { @@ -93,7 +94,8 @@ "SemanticVersionWithoutLabels": { "description": "Semantic version string *without* pre-release or build metadata (..).", "type": "string", - "pattern": "^\\d+\\.\\d+\\.\\d+$" + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "lpegPattern": "%d+ '.' %d+ '.' %d+ !." }, "TemplateString": { @@ -104,7 +106,10 @@ "Sha1Hash": { "description": "SHA-1 digest as 40 hexadecimal characters (case-insensitive).", "type": "string", - "pattern": "^[0-9A-Fa-f]{40}$" + "pattern": "^[0-9A-Fa-f]{40}$", + "lpegPattern": "[0-9A-Fa-f]+ !.", + "minLength": 40, + "maxLength": 40 }, "PackagedFile": { From 50a5723a81c4b25bfa12124551e920f811e57317 Mon Sep 17 00:00:00 2001 From: line0 Date: Mon, 8 Jun 2026 00:47:45 +0200 Subject: [PATCH 43/47] fix: FileOps.move not recursively creating dirs; wrong error messages --- modules/l0/DependencyControl/FileOps.moon | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/l0/DependencyControl/FileOps.moon b/modules/l0/DependencyControl/FileOps.moon index 6be02a7..65e5cb8 100644 --- a/modules/l0/DependencyControl/FileOps.moon +++ b/modules/l0/DependencyControl/FileOps.moon @@ -57,7 +57,7 @@ class FileOps createdDir: "Created target directory '%s'." exists: "Couldn't move file '%s' to '%s' because a %s of the same name is already present." genericError: "An error occured while moving file '%s' to '%s':\n%s" - createDirError: "Moving '%s' to '%s' failed (%s)." + createDirError: "Could not create target directory for '%s': %s" cantRemove: "Couldn't overwrite file '%s': %s. Attempts at renaming the existing target file failed." cantRenameTryingCopy: "Move operation failed to rename '%s' to '%s' (%s), trying copy+remove instead..." 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" @@ -345,11 +345,11 @@ class FileOps return false, msgs.move.genericError\format source, target, err else -- target file not found, check directory - res, dir = FileOps.mkdir target, true + res, dirOrErr = FileOps.mkdir target, true, true if res == nil - return false, msgs.move.createDirError\format source, target, err + return false, msgs.move.createDirError\format source, target, dirOrErr elseif res - FileOps.logger\trace msgs.move.createdDir, dir + FileOps.logger\trace msgs.move.createdDir, dirOrErr -- at this point the target directory exists and the target file doesn't, move the file res, err = os.rename source, target From be570e0492712ba0a74bea11520ca5dc306aaaf7 Mon Sep 17 00:00:00 2001 From: line0 Date: Mon, 8 Jun 2026 00:48:37 +0200 Subject: [PATCH 44/47] feat: aegisub shim adds ?user directory to lua/moon search path --- modules/l0/AegisubShims/aegisub.moon | 34 +++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/modules/l0/AegisubShims/aegisub.moon b/modules/l0/AegisubShims/aegisub.moon index b2fcc99..bf46e24 100644 --- a/modules/l0/AegisubShims/aegisub.moon +++ b/modules/l0/AegisubShims/aegisub.moon @@ -16,6 +16,11 @@ userDir = os.getenv("DEPCTRL_USER_DIR") or (isWindows and "#{os.getenv 'APPDATA'}\\Aegisub" or "#{os.getenv 'HOME'}/.aegisub") dataDir = os.getenv("DEPCTRL_DATA_DIR") or userDir +userPathsAddedToPackagePathLua = {} +userPathsAddedToPackagePathMoon = {} + +makePackagePaths = (dir, ext) -> {"#{dir}/?.#{ext}", "#{dir}/?/init.#{ext}"} + -- Canonical token table matching libaegisub/path.cpp. -- Empty string means "unset" — decode_path returns the path unchanged (same as real Aegisub). -- ?audio, ?script, ?video are empty because no file is loaded headlessly. @@ -50,8 +55,35 @@ normalizeToken = (spec) -> -- @param dir string|nil the directory to resolve the token to; nil/"" marks it unset -- @return string|nil dir the value the token now resolves to setPathToken = (spec, dir) -> - pathTokens[normalizeToken spec] = dir or "" + normalizedToken = normalizeToken spec + previousDir = pathTokens[normalizedToken] + return dir if previousDir == dir + + pathTokens[normalizedToken] = dir or "" rebuildSortedTokens! + + if normalizedToken == "?user" + -- undo our previous additions to path list, add new ones that aren't already present, + -- and ensure the order of existing entries is unchanged to avoid messing up module shadowing + rebuildUserPaths = (pathStr, previouslyAdded, ext) -> + removed = {p, true for p in *previouslyAdded} + seen, ordered = {}, {} + for path in pathStr\gmatch "[^;]+" + continue if removed[path] or seen[path] + seen[path] = true + ordered[#ordered + 1] = path + + added = {} + for path in *makePackagePaths "#{dir}/automation/modules", ext + continue if seen[path] + seen[path] = true + ordered[#ordered + 1] = path + added[#added + 1] = path + + table.concat(ordered, ";"), added + + package.path, userPathsAddedToPackagePathLua = rebuildUserPaths package.path, userPathsAddedToPackagePathLua, "lua" + package.moonpath, userPathsAddedToPackagePathMoon = rebuildUserPaths package.moonpath, userPathsAddedToPackagePathMoon, "moon" return dir --- Returns the directory an Aegisub path token currently resolves to. From 5d4a6d087f437c116a1ec611166e7e6067e37dda Mon Sep 17 00:00:00 2001 From: line0 Date: Mon, 8 Jun 2026 00:49:58 +0200 Subject: [PATCH 45/47] fix: downloader not creating target file directory structure recursively required for DM.DownloadManager compatibility --- modules/l0/DependencyControl/Downloader.moon | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/l0/DependencyControl/Downloader.moon b/modules/l0/DependencyControl/Downloader.moon index 2faa9fd..a5ff609 100644 --- a/modules/l0/DependencyControl/Downloader.moon +++ b/modules/l0/DependencyControl/Downloader.moon @@ -499,8 +499,7 @@ class Downloader extends EventEmitter 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" + FileOps.mkdir outfile, true, true @_lastId = (@_lastId or 0) + 1 download = Download url, outfile, @_lastId From 53f1ebd2d7bb6531ece613ffa6f573aeb5f37a7b Mon Sep 17 00:00:00 2001 From: line0 Date: Mon, 8 Jun 2026 01:33:49 +0200 Subject: [PATCH 46/47] fix: wrong DepCtrl-related file names, log tags and other inconsistencies e.g. config file called "l0.Record.json" instead of "l0.DependencyControl.json" --- modules/l0/AegisubShims.moon | 4 +-- modules/l0/AegisubShims/aegisub.moon | 2 +- .../l0/DependencyControl/ConfigHandler.moon | 10 ++++---- modules/l0/DependencyControl/Constants.moon | 6 +++++ modules/l0/DependencyControl/FileOps.moon | 15 +++++------ .../l0/DependencyControl/ModuleLoader.moon | 11 +++++--- .../l0/DependencyControl/ModuleProvider.moon | 11 +++++--- modules/l0/DependencyControl/Record.moon | 13 +++++----- .../l0/DependencyControl/UnitTestSuite.moon | 2 +- modules/l0/DependencyControl/UpdateFeed.moon | 20 +++++++-------- modules/l0/DependencyControl/test.moon | 25 +++++++++++-------- .../test/ModuleProvider.moon | 15 ++++++----- 12 files changed, 77 insertions(+), 57 deletions(-) create mode 100644 modules/l0/DependencyControl/Constants.moon diff --git a/modules/l0/AegisubShims.moon b/modules/l0/AegisubShims.moon index 1f9b437..d7bb826 100644 --- a/modules/l0/AegisubShims.moon +++ b/modules/l0/AegisubShims.moon @@ -4,6 +4,6 @@ aegisub = require "l0.AegisubShims.aegisub" -- relocate path tokens without reaching into the faux `aegisub` global. return { :aegisub - setPathToken: aegisub.__depctrl.setPathToken - getPathToken: aegisub.__depctrl.getPathToken + setPathToken: aegisub.__depCtrl.setPathToken + getPathToken: aegisub.__depCtrl.getPathToken } diff --git a/modules/l0/AegisubShims/aegisub.moon b/modules/l0/AegisubShims/aegisub.moon index bf46e24..f135c55 100644 --- a/modules/l0/AegisubShims/aegisub.moon +++ b/modules/l0/AegisubShims/aegisub.moon @@ -164,7 +164,7 @@ aegisub = { -- Shim-only configuration hooks, namespaced so they can't collide with the real -- Aegisub API surface. Surfaced through l0.AegisubShims for callers to use. -aegisub.__depctrl = { +aegisub.__depCtrl = { :setPathToken :getPathToken } diff --git a/modules/l0/DependencyControl/ConfigHandler.moon b/modules/l0/DependencyControl/ConfigHandler.moon index c922fd4..b5ff047 100644 --- a/modules/l0/DependencyControl/ConfigHandler.moon +++ b/modules/l0/DependencyControl/ConfigHandler.moon @@ -1,8 +1,8 @@ json = require "json" - -fileOps = require "l0.DependencyControl.FileOps" -Logger = require "l0.DependencyControl.Logger" -Lock = require "l0.DependencyControl.Lock" +constants = require "l0.DependencyControl.Constants" +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. @@ -69,7 +69,7 @@ Reload your automation scripts to generate a new configuration file.]] -- make references to provided handlers weak to allow for gc @handlers = setmetatable {}, {__mode: 'v'} - @logger = Logger fileBaseName: "DepCtrl.ConfigHandler", fileSubName: script_namespace + @logger = Logger fileBaseName: "#{constants.DEPCTRL_SHORT_NAME}.#{@__name}", fileSubName: script_namespace --- Returns an existing handler for filePath, or creates and optionally loads one. -- @param filePath string diff --git a/modules/l0/DependencyControl/Constants.moon b/modules/l0/DependencyControl/Constants.moon new file mode 100644 index 0000000..b69f06f --- /dev/null +++ b/modules/l0/DependencyControl/Constants.moon @@ -0,0 +1,6 @@ +{ + DEPCTRL_NAME: "DependencyControl" + DEPCTRL_SHORT_NAME: "DepCtrl" + DEPCTRL_NAMESPACE: "l0.DependencyControl" + DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX: "__depCtrl" +} diff --git a/modules/l0/DependencyControl/FileOps.moon b/modules/l0/DependencyControl/FileOps.moon index 65e5cb8..9d57cf8 100644 --- a/modules/l0/DependencyControl/FileOps.moon +++ b/modules/l0/DependencyControl/FileOps.moon @@ -1,5 +1,6 @@ ffi = require "ffi" lfs = require "lfs" +constants = require "l0.DependencyControl.Constants" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" Crypto = require "l0.DependencyControl.Crypto" @@ -21,7 +22,7 @@ class FileOps attributes: { badPath: "Path failed verification: %s." genericError: "Can't retrieve attributes: %s." - noAttribute: "Can't find attriubte with name '%s'." + noAttribute: "Can't find attribute with name '%s'." } createConfig: { @@ -35,7 +36,7 @@ class FileOps otherExists: "Couldn't create directory because a %s of the same name is already present." } copy: { - genericError: "An error occured while copying file '%s' to '%s':\n%s" + genericError: "An error occurred while copying file '%s' to '%s':\n%s" dirCopyUnsupported: "Copying directories is currently not supported." missingSource: "Couldn't find source file '%s'." openError: "Couldn't open %s file '%s' for reading: \n%s" @@ -56,11 +57,11 @@ class FileOps overwritingFile: "File '%s' already exists, overwriting..." createdDir: "Created target directory '%s'." exists: "Couldn't move file '%s' to '%s' because a %s of the same name is already present." - genericError: "An error occured while moving file '%s' to '%s':\n%s" + genericError: "An error occurred while moving file '%s' to '%s':\n%s" createDirError: "Could not create target directory for '%s': %s" cantRemove: "Couldn't overwrite file '%s': %s. Attempts at renaming the existing target file failed." cantRenameTryingCopy: "Move operation failed to rename '%s' to '%s' (%s), trying copy+remove instead..." - 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" + couldntRemoveFiles: "Move operation succeeded 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: { @@ -125,9 +126,9 @@ class FileOps createConfig = (noLoad, configDir) -> FileOps.configDir = configDir if configDir - ConfigView or= require "#{Common.moduleName}.ConfigView" + ConfigView or= require "#{constants.DEPCTRL_NAMESPACE}.ConfigView" unless FileOps.config - FileOps.config = ConfigView\get "#{FileOps.configDir}/l0.#{FileOps.__name}.json", + FileOps.config = ConfigView\get "#{FileOps.configDir}/#{constants.DEPCTRL_NAMESPACE}.json", nil, {toRemove: {}}, FileOps.logger, noLoad return nil, msgs.createConfig.handlerFailed\format "constructor returned nil" unless FileOps.config return FileOps.config @@ -144,7 +145,7 @@ class FileOps --- 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}" + return aegisub.decode_path "?temp/#{constants.DEPCTRL_NAMESPACE}_#{'%04X'\format math.random 0, 16^4-1}" --- Removes one or more files/directories and optionally reschedules failed removals. -- @param paths string|(string|string)[] path or paths to the files/directories to remove. If an array of paths is provided, each path can be specified as a string or an array of path segments. diff --git a/modules/l0/DependencyControl/ModuleLoader.moon b/modules/l0/DependencyControl/ModuleLoader.moon index 5b21195..76c9c5c 100644 --- a/modules/l0/DependencyControl/ModuleLoader.moon +++ b/modules/l0/DependencyControl/ModuleLoader.moon @@ -2,8 +2,11 @@ -- Everything in this class can and will change without any prior notice -- and calling any method is guaranteed to interfere with DependencyControl operation +constants = require "l0.DependencyControl.Constants" SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" -ModuleProvider = require "l0.DependencyControl.ModuleProvider" +ModuleProvider = require "l0.DependencyControl.ModuleProvider" + +DEPCTRL_DUMMY_MODULE_MARKER = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Dummy" --- Internal module loading helpers for DependencyControl-managed module dependencies. -- @class ModuleLoader @@ -44,13 +47,13 @@ class ModuleLoader export LOADED_MODULES = {} unless LOADED_MODULES unless LOADED_MODULES[@namespace] @ref = {} - LOADED_MODULES[@namespace] = setmetatable {__depCtrlDummy: true, version: @}, @ref + LOADED_MODULES[@namespace] = setmetatable {[DEPCTRL_DUMMY_MODULE_MARKER]: true, version: @}, @ref return true return false @removeDummyRef = => return nil if @scriptType != @@ScriptType.Module - if LOADED_MODULES[@namespace] and LOADED_MODULES[@namespace].__depCtrlDummy + if LOADED_MODULES[@namespace] and LOADED_MODULES[@namespace][DEPCTRL_DUMMY_MODULE_MARKER] LOADED_MODULES[@namespace] = nil return true return false @@ -81,7 +84,7 @@ class ModuleLoader return nil -- set new references - if reload and ._ref and ._ref.__depCtrlDummy + if reload and ._ref and ._ref[DEPCTRL_DUMMY_MODULE_MARKER] setmetatable ._ref, res ._ref, LOADED_MODULES[moduleName] = res, res diff --git a/modules/l0/DependencyControl/ModuleProvider.moon b/modules/l0/DependencyControl/ModuleProvider.moon index 12bdc44..0baf68c 100644 --- a/modules/l0/DependencyControl/ModuleProvider.moon +++ b/modules/l0/DependencyControl/ModuleProvider.moon @@ -1,3 +1,5 @@ +constants = require "l0.DependencyControl.Constants" + -- Resolves provided module aliases (e.g. "json") to their provider module -- (e.g. "l0.dkjson") through a custom package searcher. -- @@ -10,7 +12,8 @@ -- DependencyControl self-update reloads. -- @class ModuleProvider -GLOBAL_KEY = "__depCtrlModuleProvider" +DEPCTRL_MODULE_INIT_HOOK_NAME = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Init" +GLOBAL_KEY = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}ModuleProvider" state = _G[GLOBAL_KEY] unless state @@ -21,12 +24,12 @@ unless state -- hasn't been initialized yet, so the module exposes a proper DependencyControl record. The guard -- avoids re-initializing modules that mutate their exported state on first init (e.g. BadMutex). runInitializer = (ref, DependencyControl) -> - return ref unless type(ref) == "table" and ref.__depCtrlInit + return ref unless type(ref) == "table" and ref[DEPCTRL_MODULE_INIT_HOOK_NAME] -- 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 alreadyInitialized = type(ref.version) == "table" and ref.version.__class and ref.version.__class.__name == DependencyControl.__name - ref.__depCtrlInit DependencyControl unless alreadyInitialized + ref[DEPCTRL_MODULE_INIT_HOOK_NAME] DependencyControl unless alreadyInitialized return ref -- Searcher-side initializer: resolves DependencyControl from package.loaded rather than require()ing @@ -34,7 +37,7 @@ runInitializer = (ref, DependencyControl) -> -- the type check also rejects the mid-bootstrap "loading" sentinel). Until the real class is loaded -- there's nothing to init against, so the module is returned as-is. initProvidedModule = (mod) -> - DependencyControl = package.loaded["l0.DependencyControl"] + DependencyControl = package.loaded[constants.DEPCTRL_NAMESPACE] return mod unless type(DependencyControl) == "table" runInitializer mod, DependencyControl diff --git a/modules/l0/DependencyControl/Record.moon b/modules/l0/DependencyControl/Record.moon index fafc21d..d6e2730 100644 --- a/modules/l0/DependencyControl/Record.moon +++ b/modules/l0/DependencyControl/Record.moon @@ -1,6 +1,7 @@ json = require "json" lfs = require "lfs" +constants = require "l0.DependencyControl.Constants" Common = require "l0.DependencyControl.Common" Logger = require "l0.DependencyControl.Logger" ConfigView = require "l0.DependencyControl.ConfigView" @@ -14,7 +15,7 @@ UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" -- Global registry of live DepCtrl version records keyed by namespace, backed by a global table -- so it survives DepCtrl self-update reloads. Required to reach the DepCtrl version records -- of automation scripts/macros, which don't expose it globally (only a few script_* globals) -DEPCTRL_RECORDS_GLOBAL_KEY = "__depCtrlRecords" +DEPCTRL_RECORDS_GLOBAL_KEY = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Records" recordsByNamespace = _G[DEPCTRL_RECORDS_GLOBAL_KEY] unless recordsByNamespace recordsByNamespace = {} @@ -37,7 +38,7 @@ unregisterRecord = (namespace) -> recordsByNamespace[namespace] = nil class Record extends Common msgs = { new: { - badRecordError: "Error: Bad #{@@__name} record (%s)." + badRecordError: "Error: Bad #{constants.DEPCTRL_NAME} record (%s)." badRecord: { noUnmanagedMacros: "Creating unmanaged version records for macros is not allowed" missingNamespace: "No namespace defined" @@ -47,16 +48,16 @@ class Record extends Common } } uninstall: { - noVirtualOrUnmanaged: "Can't uninstall %s %s '%s'. (Only installed scripts managed by #{@@__name} can be uninstalled)." + noVirtualOrUnmanaged: "Can't uninstall %s %s '%s'. (Only installed scripts managed by #{constants.DEPCTRL_NAME} can be uninstalled)." } writeConfig: { - error: "An error occured while writing the #{@@__name} config file: %s" + error: "An error occurred while writing the #{constants.DEPCTRL_NAME} config file: %s" writing: "Writing updated %s data to config file..." } } @depConf = { - file: aegisub.decode_path "?user/config/l0.#{@@__name}.json", + file: aegisub.decode_path "?user/config/#{constants.DEPCTRL_NAMESPACE}.json", scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", -- REMOVE "requiredModules", "version", "unmanaged", "provides"}, globalDefaults: {updaterEnabled:true, updateInterval:302400, traceLevel:3, extraFeeds:{}, @@ -77,7 +78,7 @@ class Record extends Common init = => FileOps.mkdir @depConf.file, true @loadConfig! - @logger = Logger { fileBaseName: "DepCtrl", fileSubName: script_namespace, prefix: "[#{@@__name}] ", + @logger = Logger { fileBaseName: constants.DEPCTRL_SHORT_NAME, fileSubName: script_namespace, prefix: "[#{constants.DEPCTRL_SHORT_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 } diff --git a/modules/l0/DependencyControl/UnitTestSuite.moon b/modules/l0/DependencyControl/UnitTestSuite.moon index fbdb579..7302b03 100644 --- a/modules/l0/DependencyControl/UnitTestSuite.moon +++ b/modules/l0/DependencyControl/UnitTestSuite.moon @@ -927,7 +927,7 @@ class UnitTestSuite -- @treturn[2] nil -- @treturn[2] string an error message writeResults: (path) => - FileOps = require "#{Common.moduleName}.FileOps" + FileOps = require "l0.DependencyControl.FileOps" json = require "json" -- provided by DepCtrl (bundled dkjson) once it's loaded dirRes, err = FileOps.mkdir path, true, true diff --git a/modules/l0/DependencyControl/UpdateFeed.moon b/modules/l0/DependencyControl/UpdateFeed.moon index 671b328..f0d7bbd 100644 --- a/modules/l0/DependencyControl/UpdateFeed.moon +++ b/modules/l0/DependencyControl/UpdateFeed.moon @@ -1,12 +1,12 @@ -- We ship dkjson, so depend on it directly: it guarantees the `null` sentinel, dkjson's encode -- options, and our `indentMode: "prettier"` extension used for feed write-back. dkjson = require "l0.dkjson" - -Logger = require "l0.DependencyControl.Logger" -Common = require "l0.DependencyControl.Common" -Enum = require "l0.DependencyControl.Enum" -FileOps = require "l0.DependencyControl.FileOps" -ModuleProvider = require "l0.DependencyControl.ModuleProvider" +constants = require "l0.DependencyControl.Constants" +Logger = require "l0.DependencyControl.Logger" +Common = require "l0.DependencyControl.Common" +Enum = require "l0.DependencyControl.Enum" +FileOps = require "l0.DependencyControl.FileOps" +ModuleProvider = require "l0.DependencyControl.ModuleProvider" SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" @@ -173,8 +173,8 @@ class UpdateFeed extends Common baseDir = fileType == "test" and Common\getTestDir(scriptType, rootDir) or Common\getAutomationDir scriptType, rootDir return FileOps.validateFullPath "#{subDir}#{fileName}", false, baseDir - fileBaseName = "l0.#{@@__name}_" - fileMatchTemplate = "l0.#{@@__name}_%x%x%x%x.*%.json" + fileBaseName = "#{constants.DEPCTRL_NAMESPACE}_" + fileMatchTemplate = "#{constants.DEPCTRL_NAMESPACE}_%x%x%x%x.*%.json" feedsHaveBeenTrimmed = false -- precalculate some tables for the templater @@ -228,7 +228,7 @@ class UpdateFeed extends Common fetch: (fileName, expansionMode) => -- Initialize download infrastructure lazily on first fetch. unless @downloadManager - @config.downloadPath or= aegisub.decode_path "?temp/l0.#{@@__name}_feedCache" + @config.downloadPath or= aegisub.decode_path "?temp/#{constants.DEPCTRL_NAMESPACE}_feedCache" feedsHaveBeenTrimmed or= Logger(fileMatchTemplate: fileMatchTemplate, logDir: @config.downloadPath, maxFiles: 20)\trimFiles! @fileName or= table.concat {@config.downloadPath, fileBaseName, "%04X"\format(math.random 0, 16^4-1), ".json"} @downloadManager = (require "DM.DownloadManager") aegisub.decode_path @config.downloadPath @@ -485,7 +485,7 @@ class UpdateFeed extends Common -- so simply by running, modules by constructing their record at load. Modules that defer to -- a lazy __depCtrlInit (e.g. dkjson) are initialized explicitly below. The record is then -- looked up from the registry — the only place a macro's record (and its deps) is reachable. - DependencyControl = require Common.moduleName + DependencyControl = require "l0.DependencyControl" success, mod = xpcall require, debug.traceback, packageNamespace ModuleProvider.runInitializer mod, DependencyControl if success diff --git a/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon index 792c110..6f01c20 100644 --- a/modules/l0/DependencyControl/test.moon +++ b/modules/l0/DependencyControl/test.moon @@ -1,6 +1,7 @@ +constants = require "l0.DependencyControl.Constants" DependencyControl = require "l0.DependencyControl" -DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> +DependencyControl.UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> -- The suite controls object is appended by UnitTestSuite\import as the final argument. -- Its index varies by loader (CLI vs Aegisub pass different arg counts), so grab the last one. nArgs = select "#", ... @@ -27,10 +28,12 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> ModuleProvider = require "l0.DependencyControl.ModuleProvider" Stub = require "l0.DependencyControl.Stub" - BADMUTEX_MODULE_NAME = "BM.BadMutex" - TIMER_MODULE_NAME = "l0.DependencyControl.Timer" - FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" - JSON_MODULE_NAME = "json" + BADMUTEX_MODULE_NAME = "BM.BadMutex" + TIMER_MODULE_NAME = "l0.DependencyControl.Timer" + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + JSON_MODULE_NAME = "json" + DEPCTRL_DUMMY_MODULE_MARKER = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Dummy" + DEPCTRL_RECORDS_GLOBAL_KEY = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Records" isWindows = ffi.os == "Windows" pathSep = isWindows and "\\" or "/" @@ -1661,7 +1664,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> result = ModuleLoader.createDummyRef rec ut\assertTrue result ut\assertNotNil LOADED_MODULES[ns] - ut\assertTrue LOADED_MODULES[ns].__depCtrlDummy + ut\assertTrue LOADED_MODULES[ns][DEPCTRL_DUMMY_MODULE_MARKER] LOADED_MODULES[ns] = nil createDummyRef_existingRef: (ut) -> @@ -1684,7 +1687,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> 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} + LOADED_MODULES[ns] = {[DEPCTRL_DUMMY_MODULE_MARKER]: true} result = ModuleLoader.removeDummyRef rec ut\assertTrue result ut\assertNil LOADED_MODULES[ns] @@ -1693,7 +1696,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> 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} + LOADED_MODULES[ns] = {[DEPCTRL_DUMMY_MODULE_MARKER]: false} result = ModuleLoader.removeDummyRef rec ut\assertFalse result LOADED_MODULES[ns] = nil @@ -1947,7 +1950,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> registry_getReturnsRegistered: (ut) -> ns = uniqueName "regns" rec = {namespace: ns} - _G.__depCtrlRecords[ns] = rec + _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = rec ut\assertIs Record\getRecord(ns), rec registry_getMissing: (ut) -> @@ -1955,7 +1958,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> registry_getSkipsVirtual: (ut) -> ns = uniqueName "virtns" - _G.__depCtrlRecords[ns] = {namespace: ns, virtual: true} + _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = {namespace: ns, virtual: true} ut\assertNil Record\getRecord ns -- a virtual placeholder flipped to non-virtual in place (as the Updater does on install) @@ -1963,7 +1966,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl, ...) -> registry_returnsAfterUnvirtualized: (ut) -> ns = uniqueName "virtns" rec = {namespace: ns, virtual: true} - _G.__depCtrlRecords[ns] = rec + _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = rec ut\assertNil Record\getRecord ns rec.virtual = false ut\assertIs Record\getRecord(ns), rec diff --git a/modules/l0/DependencyControl/test/ModuleProvider.moon b/modules/l0/DependencyControl/test/ModuleProvider.moon index 247b5f7..81e9ff7 100644 --- a/modules/l0/DependencyControl/test/ModuleProvider.moon +++ b/modules/l0/DependencyControl/test/ModuleProvider.moon @@ -2,8 +2,11 @@ -- __depCtrlInit runner. Called from test.moon as: (require "...test.ModuleProvider") basePath, DepCtrl -- (Names are unique per run since the provider registry is process-global.) (basePath, DepCtrl) -> - ModuleProvider = require "l0.DependencyControl.ModuleProvider" + constants = require "l0.DependencyControl.Constants" + ModuleProvider = require "l0.DependencyControl.ModuleProvider" SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + + DEPCTRL_MODULE_INIT_HOOK_NAME = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Init" uniqueName = (prefix) -> "#{prefix}_#{'%08X'\format math.random 0, 16^8-1}" { @@ -41,20 +44,20 @@ -- a plain module with no initializer is returned untouched runInitializer_noInitHook: (ut) -> ref = {version: "1.0.0"} - ut\assertIs ModuleProvider.runInitializer(ref, {__name: "DependencyControl"}), ref + ut\assertIs ModuleProvider.runInitializer(ref, {__name: constants.DEPCTRL_NAME}), ref -- an uninitialized module (raw .version) gets its initializer run with the DepCtrl class runInitializer_runsWhenUninitialized: (ut) -> - fakeDC, received = {__name: "DependencyControl"}, {} - ref = {version: "raw-version-string", __depCtrlInit: (dc) -> received[#received + 1] = dc} + fakeDC, received = {__name: constants.DEPCTRL_NAME}, {} + ref = {version: "raw-version-string", [DEPCTRL_MODULE_INIT_HOOK_NAME]: (dc) -> received[#received + 1] = dc} ModuleProvider.runInitializer ref, fakeDC ut\assertEquals #received, 1 ut\assertIs received[1], fakeDC -- a module whose .version is already a DepCtrl record must NOT be re-initialized runInitializer_skipsWhenInitialized: (ut) -> - fakeDC, calls = {__name: "DependencyControl"}, 0 - ref = {version: {__class: {__name: "DependencyControl"}}, __depCtrlInit: -> calls += 1} + fakeDC, calls = {__name: constants.DEPCTRL_NAME}, 0 + ref = {version: {__class: {__name: constants.DEPCTRL_NAME}}, [DEPCTRL_MODULE_INIT_HOOK_NAME]: -> calls += 1} ModuleProvider.runInitializer ref, fakeDC ut\assertEquals calls, 0 From 71b12d5ce2d4a69588261b02a3b60da48b087403 Mon Sep 17 00:00:00 2001 From: line0 Date: Mon, 8 Jun 2026 09:38:37 +0200 Subject: [PATCH 47/47] fix: inaccurate path length limit detection in FileOps --- modules/l0/DependencyControl/FileOps.moon | 80 ++++++++++++++++++++++- modules/l0/DependencyControl/test.moon | 62 +++++++++++++++++- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/modules/l0/DependencyControl/FileOps.moon b/modules/l0/DependencyControl/FileOps.moon index 9d57cf8..bf4a3d3 100644 --- a/modules/l0/DependencyControl/FileOps.moon +++ b/modules/l0/DependencyControl/FileOps.moon @@ -12,6 +12,62 @@ ERROR_PATH_NOT_FOUND = 3 -- Windows error code for "The system cannot find the p local ConfigView +-- Filesystem path length limits. +WINDOWS_MAX_PATH = 260 -- Windows with long path support disabled +WINDOWS_LONG_PATH_MAX = 32767 -- Windows with long path support enabled +MAX_PATH_COMPONENT = 255 -- per-segment limit on NTFS and common POSIX filesystems +POSIX_PATH_MAX = 4096 -- typical full-path limit on modern POSIX systems + +-- Whether the *current process* can actually use paths beyond MAX_PATH. +-- ntdll!RtlAreLongPathsEnabled returns the effective per-process answer: it folds in +-- both the system registry policy AND the process's manifest opt-in (a process whose +-- executable manifest lacks the `longPathAware` setting stays capped at MAX_PATH even +-- when the registry enables long paths). Available since Windows 10 1607, which is +-- also when long path support was introduced -- on older systems the symbol is absent +-- and long paths are unsupported, so we correctly treat them as disabled. +detectProcessLongPathsEnabled = -> + okLib, ntdll = pcall ffi.load, "ntdll" + return false unless okLib + pcall ffi.cdef, "unsigned char RtlAreLongPathsEnabled(void);" + ok, enabled = pcall -> ntdll.RtlAreLongPathsEnabled! != 0 + return ok and enabled + +-- Reads HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled via the +-- Win32 registry API. This is the *system* policy only (it ignores the per-process +-- manifest), so it's used solely to tailor the diagnostic when a path is rejected: it +-- lets us tell apart "long paths are off system-wide" from "they're on, but this +-- application isn't long-path-aware". Returns false if missing/zero or unreadable. +detectRegistryLongPathsEnabled = -> + okLib, advapi = pcall ffi.load, "advapi32" + return false unless okLib + pcall ffi.cdef, [[ + long RegOpenKeyExA(uintptr_t hKey, const char* subKey, unsigned long options, unsigned long samDesired, uintptr_t* result); + long RegQueryValueExA(uintptr_t hKey, const char* valueName, unsigned long* reserved, unsigned long* type, unsigned char* data, unsigned long* dataSize); + long RegCloseKey(uintptr_t hKey); + ]] + -- HKEY_LOCAL_MACHINE is (HKEY)(LONG)0x80000002; the int32->uintptr cast reproduces + -- the sign-extended pointer value the API expects on both 32- and 64-bit builds. + HKEY_LOCAL_MACHINE = ffi.cast "uintptr_t", ffi.cast "int32_t", 0x80000002 + KEY_READ, ERROR_CODE_SUCCESS = 0x20019, 0 + hKey = ffi.new "uintptr_t[1]" + return false unless ERROR_CODE_SUCCESS == advapi.RegOpenKeyExA HKEY_LOCAL_MACHINE, + "SYSTEM\\CurrentControlSet\\Control\\FileSystem", 0, KEY_READ, hKey + value = ffi.new "unsigned long[1]" + size = ffi.new "unsigned long[1]", ffi.sizeof "unsigned long" + status = advapi.RegQueryValueExA hKey[0], "LongPathsEnabled", nil, nil, + ffi.cast("unsigned char*", value), size + advapi.RegCloseKey hKey[0] + return status == ERROR_CODE_SUCCESS and value[0] == 1 + +windowsProcessLongPathsEnabled, windowsRegistryLongPathsEnabled = false, false +if ffi.os == "Windows" + ok, res = pcall detectProcessLongPathsEnabled + windowsProcessLongPathsEnabled = ok and res + -- only needed to explain *why* long paths are unavailable + unless windowsProcessLongPathsEnabled + ok, res = pcall detectRegistryLongPathsEnabled + windowsRegistryLongPathsEnabled = ok and res + --- Filesystem utility helpers used by DependencyControl. -- @class FileOps class FileOps @@ -98,6 +154,9 @@ class FileOps validateFullPath: { badType: "Argument #%s (%s) had the wrong type. Expected 'string', got '%s'." tooLong: "The specified path exceeded the maximum length limit (%d > %d)." + tooLongRegistryDisabled: "The specified path exceeded the Windows MAX_PATH limit (%d > %d characters) and long path support is disabled on this system.\nEnable it by setting the registry value 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled' (DWORD) to 1 and restarting, e.g. by running this in an elevated PowerShell:\n Set-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem' -Name 'LongPathsEnabled' -Value 1 -Type DWord" + tooLongProcessUnaware: "The specified path exceeded the Windows MAX_PATH limit (%d > %d characters). Long path support is enabled system-wide, but the host application is not long-path-aware (its executable manifest lacks the 'longPathAware' setting), so paths remain capped at %d characters in this process." + segmentTooLong: "A path component exceeded the maximum length limit (%d > %d): '%s'." invalidChars: "The specified path contains one or more invalid characters: '%s'." reservedNames: "The specified path contains reserved path or file names: '%s'." parentPath: "Accessing parent directories is not allowed." @@ -122,7 +181,19 @@ class FileOps @HashType = HashType hashAlgorithms = { [HashType.SHA1]: Crypto.sha1 } @logger = Logger! - @pathMaxLength = ffi.os == "Windows" and 260 or 4096 + + -- effective full-path limit; on Windows this depends on whether *this process* + -- can use long paths (see detectProcessLongPathsEnabled) + @pathMaxLength = if ffi.os == "Windows" + windowsProcessLongPathsEnabled and WINDOWS_LONG_PATH_MAX or WINDOWS_MAX_PATH + else POSIX_PATH_MAX + @pathMaxSegmentLength = MAX_PATH_COMPONENT + -- true when running on Windows but capped at the legacy MAX_PATH limit (this process + -- can't use long paths); drives the descriptive error below. Always false off Windows. + @longPathsDisabled = ffi.os == "Windows" and not windowsProcessLongPathsEnabled + -- when capped, whether the system registry policy enables long paths -- lets the error + -- tell a system-wide opt-out apart from an app that isn't long-path-aware + @windowsRegistryLongPathsEnabled = windowsRegistryLongPathsEnabled createConfig = (noLoad, configDir) -> FileOps.configDir = configDir if configDir @@ -569,6 +640,11 @@ class FileOps path = path\gsub "[\\/]+", FileOps.pathSep -- check length if #path > FileOps.pathMaxLength + if FileOps.longPathsDisabled + -- distinguish a system-wide opt-out from an app that isn't long-path-aware + if FileOps.windowsRegistryLongPathsEnabled + return nil, msgs.validateFullPath.tooLongProcessUnaware\format #path, FileOps.pathMaxLength, FileOps.pathMaxLength + return nil, msgs.validateFullPath.tooLongRegistryDisabled\format #path, FileOps.pathMaxLength return nil, msgs.validateFullPath.tooLong\format #path, FileOps.pathMaxLength -- check for invalid characters invChar = path\match FileOps.pathMatch.invalidChars, ffi.os == "Windows" and 3 or nil @@ -589,6 +665,8 @@ class FileOps unless dir return false, msgs.validateFullPath.notFullPath for segment in FileOps.pathSegments rest + if #segment > FileOps.pathMaxSegmentLength + return nil, msgs.validateFullPath.segmentTooLong\format #segment, FileOps.pathMaxSegmentLength, segment if ffi.os == "Windows" segmentWithoutExt = segment\match("^[^%.]+") or segment if windowsReservedNameSet[segmentWithoutExt\upper!] diff --git a/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon index 6f01c20..8a36487 100644 --- a/modules/l0/DependencyControl/test.moon +++ b/modules/l0/DependencyControl/test.moon @@ -65,6 +65,19 @@ DependencyControl.UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> -- generates a process-unique module-alias name (the ModuleProvider registry is global) uniqueName = (prefix) -> "#{prefix}_#{'%08X'\format math.random 0, 16^8-1}" + -- Runs fn with FileOps' path-length detection results overridden, restoring them + -- afterwards (even if fn raises) so the platform-derived values don't leak between + -- tests. Lets us exercise every "path too long" diagnostic branch on any OS. + withPathLimits = (maxLength, longPathsDisabled, registryEnabled, fn) -> + saved = {FileOps.pathMaxLength, FileOps.longPathsDisabled, FileOps.windowsRegistryLongPathsEnabled} + FileOps.pathMaxLength = maxLength + FileOps.longPathsDisabled = longPathsDisabled + FileOps.windowsRegistryLongPathsEnabled = registryEnabled + results = table.pack pcall fn + FileOps.pathMaxLength, FileOps.longPathsDisabled, FileOps.windowsRegistryLongPathsEnabled = saved[1], saved[2], saved[3] + error results[2] unless results[1] + return unpack results, 2, results.n + { Timer: { _description: "Tests for the FFI-based Timer: monotonic timing and millisecond sleep." @@ -435,8 +448,52 @@ DependencyControl.UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> ut\assertString result -- resolves to parent dir + escape.txt validateFullPath_tooLong: (ut) -> - result = FileOps.validateFullPath {basePath, "#{string.rep 'a', 300}.txt"} + -- exceed the full-path limit on every platform/config (well past the ~32k + -- long-path-enabled Windows limit) while keeping each component within bounds + segments = [string.rep "a", 200 for _ = 1, 200] + result = FileOps.validateFullPath {basePath, segments} + ut\assertNil result + + validateFullPath_segmentTooLong: (ut) -> + -- a single component over the per-segment limit is rejected even when the overall + -- path fits the length limit (raise the length cap so the segment check is reached) + result, err = withPathLimits 32767, false, false, -> + FileOps.validateFullPath {basePath, "#{string.rep 'a', 300}.txt"} + ut\assertNil result + ut\assertContains err, "path component" + + -- detected, platform-specific path limits + pathLimits_detected: (ut) -> + ut\assertEquals FileOps.pathMaxSegmentLength, 255 + if isWindows + -- 260 (capped) or 32767 (long paths available to this process) + ut\assertTrue FileOps.pathMaxLength == 260 or FileOps.pathMaxLength == 32767 + ut\assertBoolean FileOps.longPathsDisabled + else + ut\assertEquals FileOps.pathMaxLength, 4096 + ut\assertFalse FileOps.longPathsDisabled + + -- "path too long" diagnostic selection (field-driven via withPathLimits, runs on any OS) + validateFullPath_tooLong_generic: (ut) -> + -- non-Windows / long paths available: plain limit message, no Windows-specific guidance + result, err = withPathLimits 260, false, false, -> + FileOps.validateFullPath {basePath, [string.rep "a", 200 for _ = 1, 3]} + ut\assertNil result + ut\assertContains err, "maximum length limit" + + validateFullPath_tooLong_registryDisabled: (ut) -> + -- Windows, long paths off system-wide: error explains how to enable the registry key + result, err = withPathLimits 260, true, false, -> + FileOps.validateFullPath {basePath, [string.rep "a", 200 for _ = 1, 3]} + ut\assertNil result + ut\assertContains err, "LongPathsEnabled" + + validateFullPath_tooLong_processUnaware: (ut) -> + -- Windows, registry on but app not long-path-aware: error explains the manifest cap + result, err = withPathLimits 260, true, true, -> + FileOps.validateFullPath {basePath, [string.rep "a", 200 for _ = 1, 3]} ut\assertNil result + ut\assertContains err, "long-path-aware" validateFullPath_invalidChars: (ut) -> return unless isWindows @@ -672,6 +729,9 @@ DependencyControl.UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> _order: { "validateFullPath_nonString", "validateFullPath_parentDir", "validateFullPath_tooLong", + "validateFullPath_segmentTooLong", "pathLimits_detected", + "validateFullPath_tooLong_generic", "validateFullPath_tooLong_registryDisabled", + "validateFullPath_tooLong_processUnaware", "validateFullPath_invalidChars", "validateFullPath_reservedNames", "validateFullPath_reservedNameWithExt", "validateFullPath_trailingDotSegment", "validateFullPath_valid", "validateFullPath_noExt_rejected", "validateFullPath_withExt_accepted",