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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8c0f14b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,61 @@ +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 + # moonscript loader + - 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 + - run: luarocks install pegasus + # json schema validation + - run: luarocks install lua-schema + - run: luarocks install lpeg + + - name: Run tests + timeout-minutes: 5 + run: lua depctrl.lua test + + - 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..ffd4d42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/ctrf/*.json +/dist/ +/DependencyControl-*.zip diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d5d0018 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.insertFinalNewline": true +} diff --git a/DependencyControl.json b/DependencyControl.json index 90690ce..71523c0 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", @@ -7,53 +7,52 @@ "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.1.3", - "released": "2016-01-27", + "version": "0.2.0", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "3677B2817C3D1FFE86981C8ABCC092B3D2CCEE7B" + "sha1": "077264D7771AAB8384F8AD3BDCC9040F606D5591" } ], "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,97 +62,243 @@ ], "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."] } } }, "modules": { "l0.DependencyControl": { - "url": "@{baseUrl}#@{namespace}", - "author": "line0", "name": "DependencyControl", "description": "Dependency manager and automatic script updater for Aegisub macros and modules.", - "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{scriptName}", + "author": "line0", + "url": "@{baseUrl}#@{namespace}", + "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}", + "localFileBasePath": "@{localFileBasePath}modules/@{namespacePath}", "channels": { "alpha": { - "version": "0.6.3", - "released": "2016-02-06", + "version": "0.7.0", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "76C22149258CB1189265A367C1B28046F54F8FB3" + "sha1": "688A395AE62742B496633959BA8FECCDC8E49544" + }, + { + "name": "/Common.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "6AB31EB4EDEA50A1BAB047645E18274EC8AB1FDB" }, { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "97BCD3207FE8158261FA7851057464535FCEFBC6" + "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": "D999D34DB93BA76EF0E991CEB1CD63F5CC5F8E68" + "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": "1E479FE95F0DFBEE8B098302AB589F32D0C40A00" + "sha1": "28A6A8774007AEE99F9529BC2491A688DE68BB85" + }, + { + "name": "/ModuleLoader.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "6CD3DEEEB8472768B537A4F15EEC5AE6A095CF62" + }, + { + "name": "/ModuleProvider.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "A938976FFF31034738D99B87F1BC08A301496BDC" + }, + { + "name": "/Record.moon", + "url": "@{fileBaseUrl}@{fileName}", + "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": "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": "ADAB6EFB05E08A7828DCA01BC1FC43D6482979A1" + "sha1": "046772463E64D609B41A7457552B1E2E2A12C964" }, { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1EE16D9D551FF82C2D7E448F2CD980E528874108" + "sha1": "D550DBC51FC0398F5FD7A07299BB402AF33D2152" }, { "name": "/Updater.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "A4AE061724E68B2EFBB7495A477263E1746E228A" - } - ], - "requiredModules": [ + "sha1": "E8A98A4D2C27C75DE8E8D06903722F674DE81715" + }, { - "moduleName": "requireffi.requireffi", - "version": "0.1.1", - "feed": "@{feed:ffi-experiments}" + "name": "/ZipArchiver.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "93A4D958835DECFE594F6D8CAEF050992523E4DB" }, { - "moduleName": "DM.DownloadManager", - "version": "0.3.1", - "feed": "@{feed:ffi-experiments}" + "name": ".moon", + "url": "@{fileBaseUrl}/test.moon", + "sha1": "688A395AE62742B496633959BA8FECCDC8E49544", + "type": "test" }, { - "moduleName": "BM.BadMutex", - "version": "0.1.3", - "feed": "@{feed:ffi-experiments}" + "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" }, { - "moduleName": "PT.PreciseTimer", - "version": "0.1.5", + "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.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.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' 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.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.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.", @@ -165,34 +310,66 @@ "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": { + "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": null, + "default": true, + "files": [ + { + "name": ".moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "9805D5DF27A7C12DCFF4D420B320085D1E8315EB" + }, + { + "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..c91ace9 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,108 @@ -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. -__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 > 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 +- 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 +[ffi-experiments](https://github.com/torque/ffi-experiments) +modules (_DownloadManager_, _BadMutex_, _PreciseTimer_). -### Documentation ### +--- - 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) +## 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) +6. [DependencyControl](#FIXME) +7. [Updater](#FIXME) +8. [Logger](#FIXME) +9. [ConfigHandler](#FIXME) +10. [FileOps](#FIXME) +11. [CLI](#cli) -### 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 + +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. - _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._ +2. Restart Aegisub or re-scan your autoload directory from within the Aegisub _Automation Manger_. - On Windows, this will be `%AppData%\Aegisub\automation` folder. +### Configuration -2. In Aegisub, rescan your automation folder (or restart Aegisub). +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. -#### 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. +DependencyControl stores its configuration as a JSON file in the `config` folder of your Aegisub user directory: -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. +- 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__: - -* *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. -* *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. -* *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. -* *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 ##### +**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. + +#### 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__: +**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) -* *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) +- _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 ### +## Usage for Automation Scripts -#### For Macros: #### +### 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**). @@ -106,137 +117,165 @@ 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. +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: +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 + +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"}, +} ``` ---------------------------------------------- -### Namespaces and Paths ### +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 + +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: -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. +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) -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. +**Examples**: -#### Rules for a valid namespace: #### +- `l0.ASSFoundation` +- `l0.ASSFoundation.Common` (for a separately version-controlled 'submodule') +- `l0.ASSWipe` +- `a-mo.LineCollection` - 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) +### File and Folder Structure -__Examples__: - * l0.ASSFoundation - * l0.ASSFoundation.Common (for a separately version-controlled 'submodule') - * l0.ASSWipe - * a-mo.LineCollection +The namespace of your script translates into a subtree of the **user** automation directory you can use to store your files in: -#### File and Folder Structure #### +- On Windows: `%AppData%\Aegisub\automation` +- On Linux: `~/.aegisub/automation` +- On OSX: `~/Library/Application Support/Aegisub/automation` -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). +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`, which has a flat file structure. You may **not** use subdirectories and your **file names must start with the namespace of your script**. +**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 -__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). +- `l0.ASSWipe.lua` +- `l0.ASSWipe.Addon.moon` -__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. +**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). -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 +**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: -### The Anatomy of an Updater Feed ### +- `?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` -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: +--- -*(`//` denotes a comment explaining the property above)* +## The Updater Feed -`````javascript +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)_ + +```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.", @@ -246,332 +285,437 @@ 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}", // 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": { ... } + "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": { ... } - } - -````` + // 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: -````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 -_Depth 7:_ File Information - 1. __platform__: the platform defined for this file, otherwise an empty string - 2. __fileName__: the file name +1. `@{channel}`: the channel name of this version record +2. `@{version}`: the version number as a SemVer string +_Depth 7:_ File Information -__"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, 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 ### +## 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_ 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* DependecyControlRecord__ +The constructor for a DependencyControl record. Uses the table-based signature. +**Arguments:** -The constructor for a DepedencyControl 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). - * _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. -* _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 -##### Methods ##### -__:checkVersion(*str/num* version, *str* [precision = "patch"]) --> *bool* moduleUpToDate, *str* error__ +**: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. +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. -__:checkOptionalModules(*tbl* modules) --> *bool* result, *str* errorMessage__ +**:checkOptionalModules(_tbl_ modules) --> _bool_ result, _str_ errorMessage** -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. +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__ +**: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__ +**: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 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. -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 __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__ +**: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 overriden 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 ##### +### Updater -##### Methods ##### +#### 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). _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()__ +**: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. -#### 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) +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 #### +### UpdateFeed tbd + +## CLI + +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), [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. + +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. + +The feed must have correct `localFileBasePath` entries so the CLI can resolve source files on +disk. + +| 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 | + +### `bundle` — Build a release archive + +```sh +luajit depctrl.lua bundle [--feed ] [--out-dir ] + [--target-module ] [--target-macro ] +``` + +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. + +| 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 | + +Exit code `0` = success, `1` = one or more errors. + +### `deploy` — Deploy to a local Aegisub installation + +```sh +luajit depctrl.lua deploy [--feed ] [--out-dir ] [--clobber | --no-clobber] + [--target-module ] [--target-macro ] +``` + +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/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/**"] +} diff --git a/depctrl.lua b/depctrl.lua new file mode 100644 index 0000000..69cd6f4 --- /dev/null +++ b/depctrl.lua @@ -0,0 +1,369 @@ +#!/usr/bin/env luajit +-- 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 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 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 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 ─────────────────────────────────────────── +-- 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 + +-- ── 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 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 aegisub = shims.aegisub -- pulled into local scope; global is set by the shim for sub-modules + +-- ── Shared: workspace + DepCtrl bootstrap ──────────────────────────────────── + +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 + + 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 + +-- ── Shared: feed loading, target filtering, source resolution ──────────────── + +-- 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 + +-- 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 + +-- 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" + + -- ".moon" -> "", "/Common.moon" -> ".Common", "/test/Common.moon" -> ".test.Common" + local function leafSuffix(name) + return (name:gsub("%.moon$", ""):gsub("%.lua$", ""):gsub("/", ".")) + end + + 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 + + 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 + + setupDepCtrl("tests") + local FileOps = require "l0.DependencyControl.FileOps" + + local feedPath = resolveAbsPath(args.feed) + local feed = loadFeed(feedPath) + + 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 + 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 + end + + 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) + +-- ─── bundle ─────────────────────────────────────────────────────────────────── +elseif args.command == "bundle" then + local feedPath = resolveAbsPath(args.feed) + local outputDir = resolveAbsPath(args.out_dir) + + setupDepCtrl("bundle") + + 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 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 suffix = GitRepository(feed.feedDir):getVersionSuffix() + local zipPath = outputDir .. pathSep .. (feed.data.name .. "-v%s%s.zip"):format(mainVersion, suffix) + + 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) 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) + +-- ─── 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 d66c5f6..ea8aebf 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -1,21 +1,27 @@ 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" 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 msgs = { install: { - scanning: "Scanning %d available feeds..." + scanning: "Scanning %d available feeds...", + createScriptUpdateRecordFailed: "Failed to create an update record for %s '%s' from feed %s: %s" } uninstall: { running: "Uninstalling %s '%s'..." - success: "%s '%s' was removed sucessfully. Reload your automation scripts or restart Aegisub for the changes to take effect." + success: "%s '%s' was removed successfully. Reload your automation scripts or restart Aegisub for the changes to take effect." lockedFiles: "%s Some script files are still in use and will be deleted during the next restart/reload:\n%s" error: "Error: %s" } @@ -37,7 +43,7 @@ buildInstalledDlgList = (scriptType, config, isUninstall) -> for namespace, script in pairs config.c[scriptType] continue if protectedModules[namespace] - item = "%s v%s%s"\format script.name, depRec\getVersionString(script.version), + item = "%s v%s%s"\format script.name, DepCtrl.SemanticVersioning\toString(script.version), script.activeChannel and " [#{script.activeChannel}]" or "" list[#list+1] = item table.sort list, (a, b) -> a\lower! < b\lower! @@ -77,12 +83,14 @@ getScriptListDlg = (macros, modules) -> {name: "module", class: "dropdown", x: 1, y: 1, width: 1, height: 1, items: modules, value: "" } } -runUpdaterTask = (scriptData, exhaustive) -> +runUpdaterTask = (scriptData, exhaustive, isInstall) -> return unless scriptData - task, err = DepCtrl.updater\addTask scriptData, nil, nil, exhaustive, scriptData.channel - if task then task\run! - else logger\log err - + + task, code, extErr = DepCtrl.updater\addTask scriptData, nil, nil, exhaustive, scriptData.channel + return task\run! if task + with scriptData + logger\log DepCtrl.updater\getUpdaterErrorMsg code, .moduleName or .name, + .moduleName and DepCtrl.ScriptType.Module or DepCtrl.ScriptType.Automation, isInstall, extErr -- Macros @@ -90,16 +98,22 @@ install = -> config = getConfig! addAvailableToInstall = (tbl, feed, scriptType) -> - for namespace, data in pairs feed.data[scriptType] - scriptData = feed\getScript namespace, scriptType == "modules", nil, false + scriptTypeConfigAndFeedKeyName = DepCtrl.ScriptType.name.legacy[scriptType] + + for namespace, data in pairs feed.data[scriptTypeConfigAndFeedKeyName] + scriptData, err = feed\getScript namespace, scriptType, nil, false + if err + logger\warn msgs.install.createScriptUpdateRecordFailed\format DepCtrl.terms.scriptType.singular[scriptType], namespace, feed.url, err + continue + channels, defaultChannel = scriptData\getChannels! tbl[namespace] or= {} for channel in *channels record = scriptData.data.channels[channel] - verNum = depRec\getVersionNumber record.version - unless config.c[scriptType][namespace] or (tbl[namespace][channel] and verNum < tbl[namespace][channel].verNum) + verNum = DepCtrl.SemanticVersioning\toNumber record.version + unless config.c[scriptTypeConfigAndFeedKeyName][namespace] or (tbl[namespace][channel] and verNum < tbl[namespace][channel].verNum) tbl[namespace][channel] = { name: scriptData.name, version: record.version, verNum: verNum, feed: feed.url, - default: defaultChannel == channel, moduleName: scriptType == "modules" and namespace } + default: defaultChannel == channel, moduleName: scriptType == DepCtrl.ScriptType.Module and namespace } return tbl buildDlgList = (tbl) -> @@ -120,8 +134,8 @@ install = -> logger\log msgs.install.scanning, #feeds for feed in *feeds - macros = addAvailableToInstall macros, feed, "macros" - modules = addAvailableToInstall modules, feed, "modules" + macros = addAvailableToInstall macros, feed, DepCtrl.ScriptType.Automation + modules = addAvailableToInstall modules, feed, DepCtrl.ScriptType.Module -- build macro and module lists as well as reverse mappings moduleList, moduleMap = buildDlgList modules @@ -132,8 +146,8 @@ install = -> -- create and run the update tasks macro, mdl = macroMap[res.macro], moduleMap[res.module] - runUpdaterTask mdl, false - runUpdaterTask macro, false + runUpdaterTask mdl, false, true + runUpdaterTask macro, false, true uninstall = -> doUninstall = (script) -> @@ -183,8 +197,8 @@ update = -> -- create and run the update tasks macro, mdl = macroMap[res.macro], moduleMap[res.module] - runUpdaterTask mdl, res.exhaustive - runUpdaterTask macro, res.exhaustive + runUpdaterTask mdl, res.exhaustive, false + runUpdaterTask macro, res.exhaustive, false macroConfig = -> config = getConfig "macros" @@ -213,9 +227,12 @@ macroConfig = -> config\write! +-- required to register DepCtrl test suite +DepCtrl.__class.version\register DepCtrl + depRec\registerMacros{ {"Install Script", "Installs an automation script or module on your system.", install}, {"Update Script", "Manually check and perform updates to any installed script.", update}, {"Uninstall Script", "Removes an automation script or module from your system.", uninstall}, {"Macro Configuration", "Lets you change per-automation script settings.", macroConfig}, -}, "DependencyControl" \ No newline at end of file +}, "DependencyControl" diff --git a/modules/DependencyControl.moon b/modules/DependencyControl.moon deleted file mode 100644 index b7a95c4..0000000 --- a/modules/DependencyControl.moon +++ /dev/null @@ -1,49 +0,0 @@ -MIN_MOONSCRIPT_VERSION = "0.3.0" - -SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" -moonscript = require 'moonscript.version' -assert SemanticVersioning\check(moonscript.version, MIN_MOONSCRIPT_VERSION), - [[ DependencyControl requires Moonscript v%s or later to work, -however the Version %s provided by your Aegisub installation is outdated. -Update to a recent Aegisub build to resolve this issue. -]]\format MIN_MOONSCRIPT_VERSION, moonscript.version - - -Logger = require "l0.DependencyControl.Logger" -UpdateFeed = require "l0.DependencyControl.UpdateFeed" -ConfigHandler = require "l0.DependencyControl.ConfigHandler" -FileOps = require "l0.DependencyControl.FileOps" -Updater = require "l0.DependencyControl.Updater" -UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" -Record = require "l0.DependencyControl.Record" - -class DependencyControl extends Record - @ConfigHandler = ConfigHandler - @UpdateFeed = UpdateFeed - @Logger = Logger - @Updater = Updater - @UnitTestSuite = UnitTestSuite - @FileOps = FileOps - - -rec = DependencyControl{ - name: "DependencyControl", - version: "0.6.3", - description: "Provides script management and auto-updating for Aegisub macros and modules.", - author: "line0", - url: "http://github.com/TypesettingTools/DependencyControl", - moduleName: "l0.DependencyControl", - feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json", - { - {"DM.DownloadManager", version: "0.3.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, - {"BM.BadMutex", version: "0.1.3", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, - {"PT.PreciseTimer", version: "0.1.5", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, - {"requireffi.requireffi", version: "0.1.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, - } -} -DependencyControl.__class.version = rec -LOADED_MODULES[rec.moduleName], package.loaded[rec.moduleName] = DependencyControl, DependencyControl -DependencyControl.updater\scheduleUpdate rec -rec\requireModules! - -return DependencyControl \ No newline at end of file diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon deleted file mode 100644 index 8de8024..0000000 --- a/modules/DependencyControl/Common.moon +++ /dev/null @@ -1,42 +0,0 @@ -ffi = require "ffi" - -class DependencyControlCommon - -- Some terms are shared across components - @platform = "#{ffi.os}-#{ffi.arch}" - - @terms = { - scriptType: { - singular: { "automation script", "module" } - plural: { "automation scripts", "modules" } - } - - isInstall: { - [true]: "installation" - [false]: "update" - } - - capitalize: (str) -> str[1]\upper! .. str\sub 2 - } - - -- Common enums - @RecordType = { - Managed: 1 - Unmanaged: 2 - } - - @ScriptType = { - Automation: 1 - Module: 2 - name: { - legacy: { "macros", "modules" } - canonical: {"automation", "modules"} - } - } - - automationDir: { - aegisub.decode_path("?user/automation/autoload"), - aegisub.decode_path("?user/automation/include") - } - - @testDir = {aegisub.decode_path("?user/automation/tests/DepUnit/macros"), - aegisub.decode_path("?user/automation/tests/DepUnit/modules")} \ No newline at end of file diff --git a/modules/DependencyControl/ConfigHandler.moon b/modules/DependencyControl/ConfigHandler.moon deleted file mode 100644 index 11cfacf..0000000 --- a/modules/DependencyControl/ConfigHandler.moon +++ /dev/null @@ -1,330 +0,0 @@ -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" - -class ConfigHandler - @handlers = {} - errors = { - jsonDecode: "JSON parse error: %s" - configCorrupted: [[An error occured 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..." - } - - new: (@file, defaults, @section, noLoad, @logger = Logger fileBaseName: @@__name) => - @section = {@section} if "table" != type @section - @defaults = defaults and util.deep_copy(defaults) or {} - -- register all handlers for concerted writing - @setFile @file - - -- set up user configuration and make defaults accessible - @userConfig = {} - @config = setmetatable {}, { - __index: (_, k) -> - if @userConfig and @userConfig[k] ~= nil - return @userConfig[k] - else return @defaults[k] - __newindex: (_, k, v) -> - @userConfig or= {} - @userConfig[k] = v - __len: (tbl) -> return 0 - __ipairs: (tbl) -> error "numerically indexed config hive keys are not supported" - __pairs: (tbl) -> - merged = util.copy @defaults - merged[k] = v for k, v in pairs @userConfig - return next, merged - } - @c = @config -- shortcut - - -- rig defaults in a way that writing to contained tables deep-copies the whole default - -- into the user configuration and sets the requested property there - recurse = (tbl) -> - for k,v in pairs tbl - continue if type(v)~="table" or type(k)=="string" and k\match "^__" - -- replace every table reference with an empty proxy table - -- this ensures all writes to the table get intercepted - tbl[k] = setmetatable {__key: k, __parent: tbl, __tbl: v}, { - -- make the original table the index of the proxy so that defaults can be read - __index: v - __len: (tbl) -> return #tbl.__tbl - __newindex: (tbl, k, v) -> - upKeys, parent = {}, tbl.__parent - -- trace back to defaults entry, pick up the keys along the path - while parent.__parent - tbl = parent - upKeys[#upKeys+1] = tbl.__key - parent = tbl.__parent - - -- deep copy the whole defaults node into the user configuration - -- (util.deep_copy does not copy attached metatable references) - -- make sure we copy the actual table, not the proxy - @userConfig or= {} - @userConfig[tbl.__key] = util.deep_copy @defaults[tbl.__key].__tbl - -- finally perform requested write on userdata - tbl = @userConfig[tbl.__key] - for i = #upKeys-1, 1, -1 - tbl = tbl[upKeys[i]] - tbl[k] = v - __pairs: (tbl) -> return next, tbl.__tbl - __ipairs: (tbl) -> - i, n, orgTbl = 0, #tbl.__tbl, tbl.__tbl - -> - i += 1 - return i, orgTbl[i] if i <= n - } - recurse tbl[k] - - recurse @defaults - @load! unless noLoad - - setFile: (path) => - return false unless path - if @@handlers[path] - table.insert @@handlers[path], @ - else @@handlers[path] = {@} - path, err = fileOps.validateFullPath path, true - return nil, err unless path - @file = path - return true - - 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 - - readFile: (file = @file, useLock = true, waitLockTime) => - if useLock - time, err = @getLock waitLockTime - unless time - -- handle\close! - return false, errors.failedLock\format "reading", err - - mode, file = fileOps.attributes file, "mode" - if mode == nil - @releaseLock! if useLock - return false, file - elseif not mode - @releaseLock! if useLock - @logger\trace traceMsgs.fileNotFound, @file - return nil - - handle, err = io.open file, "r" - unless handle - @releaseLock! if useLock - return false, err - - data = handle\read "*a" - success, result = 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 - - @releaseLock! if useLock - return false, errors.configCorrupted\format backup - - handle\close! - @releaseLock! if useLock - - if "table" != type result - return false, errors.jsonRoot\format type result - - return result - - load: => - return false, errors.noFile unless @file - - config, err = @readFile! - return config, err unless config - - sectionExists = true - for i=1, #@section - config = config[@section[i]] - switch type config - when "table" continue - when "nil" - config, sectionExists = {}, false - break - else return false, errors.badKey\format "retrive", i, tostring(@section[i]),type config - - @userConfig or= {} - @userConfig[k] = v for k,v in pairs config - return sectionExists - - mergeSection: (config) => - --@logger\trace traceMsgs.mergeSectionStart, @logger\dumpToString(@section), - -- @logger\dumpToString config - - section, sectionExists = config, true - -- create missing parent sections - for i=1, #@section - childSection = section[@section[i]] - if childSection == nil - -- don't create parent sections if this section is going to be deleted - unless @userConfig - sectionExists = false - break - section[@section[i]] = {} - childSection = section[@section[i]] - elseif "table" != type childSection - return false, errors.badKey\format "update", i, tostring(@section[i]),type childSection - section = childSection if @userConfig or i < #@section - -- merge our values into our section - if @userConfig - section[k] = v for k,v in pairs @userConfig - elseif sectionExists - section[@section[#@section]] = nil - - -- @logger\trace traceMsgs.mergeSectionResult, @logger\dumpToString config - return config - - delete: (concertWrite, waitLockTime) => - @userConfig = nil - return @write concertWrite, waitLockTime - - write: (concertWrite, waitLockTime) => - return false, errors.noFile unless @file - - -- get a lock to avoid concurrent config file access - time, err = @getLock waitLockTime - unless time - return false, errors.failedLock\format "writing", err - - -- read the config file - config, err = @readFile @file, false - if config == false - @releaseLock! - return false, errors.writeFailedRead\format err - @logger\trace traceMsgs.fileCreate, @file unless config - config or= {} - - -- merge in our section - -- concerted writing allows us to update a configuration file - -- shared by multiple handlers in the lua environment - handlers = concertWrite and @@handlers[@file] or {@} - for handler in *handlers - config, err = handler\mergeSection config - unless config - @releaseLock! - return false, err - - -- create JSON - success, res = pcall json.encode, config - unless success - @releaseLock! - return false, res - - -- write the whole config file in one go - handle, err = io.open(@file, "w") - unless handle - @releaseLock! - return false, err - - @logger\trace traceMsgs.writing, @file - handle\setvbuf "full", 10e6 - handle\write res - handle\flush! - handle\close! - @releaseLock! - - return true - - getLock: (waitTimeout = 5000, checkInterval = 50) => - return 0 if @hasLock - success = mutex.tryLock! - if success - @hasLock = true - return 0 - - timeout, timePassed = waitTimeout, 0 - while not success and timeout > 0 - PreciseTimer.sleep checkInterval - success = mutex.tryLock! - timeout -= checkInterval - timePassed = waitTimeout - timeout - if timePassed % (checkInterval*5) == 0 - @logger\trace traceMsgs.waitingLock, timePassed - - if success - @logger\trace traceMsgs.waitingLockFinished, timePassed - @hasLock = true - return timePassed - else - -- @logger\trace traceMsgs.waitingLockTimeout, waitTimeout/1000 - -- success, err = @releaseLock true - -- unless success - -- return false, errors.forceReleaseFailed\format err - -- @hasLock = true - --return waitTimeout - return false, errors.lockTimeout - - getSectionHandler: (section, defaults, noLoad) => - return @@ @file, defaults, section, noLoad, @logger - - releaseLock: (force) => - if @hasLock or force - @hasLock = false - mutex.unlock! - return true - return false, errors.noLock - - -- copied from Aegisub util.moon, adjusted to skip private keys - deepCopy: (tbl) => - seen = {} - copy = (val) -> - return val if type(val) != 'table' - return seen[val] if seen[val] - seen[val] = val - {k, copy(v) for k, v in pairs val when type(k) != "string" or k\sub(1,1) != "_"} - copy tbl - - import: (tbl = {}, keys, updateOnly, skipSameLengthTables) => - tbl = tbl.userConfig if tbl.__class == @@ - changesMade = false - @userConfig or= {} - keys = {key, true for key in *keys} if keys - - for k,v in pairs tbl - continue if keys and not keys[k] or @userConfig[k] == v - continue if updateOnly and @c[k] == nil - -- TODO: deep-compare tables - isTable = type(v) == "table" - if isTable and skipSameLengthTables and type(@userConfig[k]) == "table" and #v == #@userConfig[k] - continue - continue if type(k) == "string" and k\sub(1,1) == "_" - @userConfig[k] = isTable and @deepCopy(v) or v - changesMade = true - - return changesMade \ No newline at end of file diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon deleted file mode 100644 index 1369a60..0000000 --- a/modules/DependencyControl/FileOps.moon +++ /dev/null @@ -1,307 +0,0 @@ -ffi = require "ffi" -re = require "aegisub.re" -lfs = require "lfs" - -Logger = require "l0.DependencyControl.Logger" -local ConfigHandler - -class FileOps - msgs = { - generic: { - deletionRescheduled: "Another deletion attempt has been rescheduled for the next restart." - } - attributes: { - badPath: "Path failed verification: %s." - genericError: "Can't retrieve attributes: %s." - noAttribute: "Can't find attriubte with name '%s'." - } - - mkdir: { - createError: "Error creating directory: %s." - 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'." - openError: "Couldn't open %s file '%s' for reading: \n%s" - } - move: { - inUseTryingRename: "Target file '%s' already exists and appears to be in use. Trying to rename and delete existing file..." - renamedDeletionFailed: "The existing file was successfully renamed to '%s', but couldn't be deleted (%s).\n%s" - 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" - createDirError: "Moving '%s' to '%s' failed (%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" - cantCopy: "Move operation failed to copy '%s' to '%s' (%s) after a failed rename attempt (%s)." - } - 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." - - } - 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'." - 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." - missingExt: "The specified path is missing a file extension." - } - } - - devPattern = ffi.os == "Windows" and "[A-Za-z]:" or "/[^\\\\/]+" - 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! - - 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 - return FileOps.config - - remove: (paths, recurse, reSchedule) -> - config = createConfig true - configLoaded, overallSuccess, details, firstErr = false, true, {} - paths = {paths} unless type(paths) == "table" - - for path in *paths - mode, path = FileOps.attributes path, "mode" - if mode - rmFunc = mode == "file" and os.remove or FileOps.rmdir - res, err = rmFunc path, recurse - unless res - firstErr or= err - unless reSchedule -- delete operation failed entirely - details[path] = {nil, err} - overallSuccess = nil - continue - - -- load the FileOps configuration file and reschedule deletions - unless configLoaded - FileOps.config\load! - configLoaded = true - config.c.toRemove[path] = os.time! - -- mark the operations as failed "for now", indicating a second attempt has been scheduled - details[path] = {false, err} - overallSuccess = false - - -- delete operation succeeded - else details[path] = {true} - -- file not found or permission issue - else details[path] = {nil, path} - - config\write! if configLoaded - return overallSuccess, details, firstErr - - runScheduledRemoval: (configDir) -> - config = createConfig false, configDir - paths = [path for path, _ in pairs config.c.toRemove] - if #paths > 0 - -- rescheduled removals will not be rescheduled another time - FileOps.remove paths, true - config.c.toRemove = {} - config\write! - return true - - copy: ( source, target ) -> - -- source check - mode, sourceFullPath, _, _, fileName = FileOps.attributes source, "mode" - switch mode - when "directory" - return false, msgs.copy.dirCopyUnsupported - when nil - return false, msgs.copy.genericError\format source, target, sourceFullPath - when false - return false, msgs.copy.missingSource\format source - - -- target check - checkTarget = (target) -> - mode, targetFullPath = FileOps.attributes target, "mode" - switch mode - when "file" - return false, msgs.copy.targetExists\format target - when nil - return false, msgs.copy.genericError\format source, target, targetFullPath - when "directory" - target ..= "/#{fileName}" - return checkTarget target - return true, targetFullPath - - success, targetFullPath = checkTarget target - return false, targetFullPath unless success - - input, msg = io.open sourceFullPath, "rb" - unless input - return false, msgs.copy.openError\format "source", sourceFullPath, msg - - output, msg = io.open targetFullPath, "wb" - unless output - input\close! - return false, msgs.copy.openError\format "target", targetFullPath, msg - - success, msg = output\write input\read "*a" - input\close! - output\close! - - if success - return true - else - return false, msgs.copy.genericError\format sourceFullPath, targetFullPath, msg - - - move: (source, target, overwrite) -> - mode, err = FileOps.attributes target, "mode" - if mode == "file" - unless overwrite - return false, msgs.move.exists\format source, target, mode - FileOps.logger\trace msgs.move.overwritingFile, target - res, _, err = FileOps.remove target - unless res - -- can't remove old target file, probably in use or lack of permissions - -- try to rename and then delete it - FileOps.logger\debug msgs.move.inUseTryingRename, target - junkName = "#{target}.depCtrlRemoved" - -- There might be an old removed file we couldn't delete before - FileOps.remove junkName - res = os.rename target, junkName - unless res - return false, msgs.move.cantRemove\format target, err - -- rename succeeded, now clean up after ourselves - res, _, err = FileOps.remove junkName, false, true - unless res - FileOps.logger\debug msgs.move.renamedDeletionFailed, junkName, err, msgs.generic.deletionRescheduled - - elseif mode -- a directory (or something else) of the same name as the target file is already present - return false, msgs.move.exists\format source, target, mode - elseif mode == nil -- if retrieving the attributes of a file fails, something is probably wrong - return false, msgs.move.genericError\format source, target, err - - else -- target file not found, check directory - res, dir = FileOps.mkdir target, true - if res == nil - return false, msgs.move.createDirError\format source, target, err - elseif res - FileOps.logger\trace msgs.move.createdDir, dir - - -- at this point the target directory exists and the target file doesn't, move the file - res, err = os.rename source, target - unless res - -- renaming the file failed, could be because of a permission issue - -- but me might a well be trying to rename over file system boundaries on *nix - -- so we should try copy + remove before giving up - FileOps.logger\debug msgs.move.cantRenameTryingCopy, source, target, err - renErr, res, err = err, FileOps.copy source, target - unless res - return false, msgs.move.cantCopy\format source, target, err, renErr - res, details = FileOps.remove source, false, true -- TODO: also support directories/recursion, but also require copy to support it - - unless res - fileList = table.concat ["#{path}: #{res[2]}" for path, res in pairs details when not res[1]], "\n" - FileOps.logger\debug msgs.move.couldntRemoveFiles, fileList, msgs.generic.deletionRescheduled - - return true - - rmdir: (path, recurse = true) -> - return nil, msgs.rmdir.emptyPath if path == "" - mode, path = FileOps.attributes path, "mode" - return nil, msgs.rmdir.notPath unless mode == "directory" - - if recurse - -- recursively remove contained files and directories - toRemove = ["#{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" - return nil, msgs.rmdir.couldntRemoveFiles\format fileList - - -- remove empty directory - success, err = lfs.rmdir path - unless success - return nil, msgs.rmdir.couldntRemoveDir\format err - - return true - - mkdir: (path, isFile) -> - 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 - res, err = lfs.mkdir dir - if err -- can't create directory (possibly a permission error) - return nil, msgs.mkdir.createError\format err - return true, dir - elseif isFile and mode == "file" -- if the file already exists, so does the directory - return false, dir - elseif mode != "directory" -- a file of the same name as the target directory is already present - return nil, msgs.mkdir.otherExists\format mode - return false, dir - - attributes: (path, key) -> - fullPath, dev, dir, file = FileOps.validateFullPath path - unless fullPath - path = "#{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 - return false, fullPath, dev, dir, file - - return attr, fullPath, dev, dir, file - - validateFullPath: (path, checkFileExt) -> - if type(path) != "string" - return nil, msgs.validateFullPath.badType\format type(path) - -- 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 - -- check length - if #path > pathMatch.maxLen - return false, msgs.validateFullPath.tooLong\format #path, pathMatch.maxLen - -- check for invalid characters - 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 - unless dev - return false, msgs.validateFullPath.notFullPath - if checkFileExt and not (file and file\match ".+%.+") - return false, msgs.validateFullPath.missingExt - - path = table.concat({dev, dir, file and pathMatch.sep, file}) - - return path, dev, dir, file \ No newline at end of file diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon deleted file mode 100644 index 7ec8035..0000000 --- a/modules/DependencyControl/UpdateFeed.moon +++ /dev/null @@ -1,250 +0,0 @@ -json = require "json" -DownloadManager = require "DM.DownloadManager" - -DependencyControl = nil -Logger = require "l0.DependencyControl.Logger" -Common = require "l0.DependencyControl.Common" - -defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" - -class ScriptUpdateRecord extends Common - msgs = { - errors: { - noActiveChannel: "No active channel." - } - changelog: { - header: "Changelog for %s v%s (released %s):" - verTemplate: "v %s:" - msgTemplate: " • %s" - } - } - - new: (@namespace, @data, @config = {c:{}}, scriptType, autoChannel = true, @logger = defaultLogger) => - DependencyControl or= require "l0.DependencyControl" - @moduleName = scriptType == @@ScriptType.Module and @namespace - @[k] = v for k, v in pairs data - @setChannel! if autoChannel - - - getChannels: => - channels, default = {} - for name, channel in pairs @data.channels - channels[#channels+1] = name - if channel.default and not default - default = name - - return channels, default - - setChannel: (channelName = @config.c.activeChannel) => - with @config.c - .channels, default = @getChannels! - .lastChannel or= channelName or default - channelData = @data.channels[.lastChannel] - @activeChannel = .lastChannel - return false, @activeChannel unless channelData - @[k] = v for k, v in pairs channelData - - @files = @files and [file for file in *@files when not file.platform or file.platform == @@platform] or {} - return true, @activeChannel - - checkPlatform: => - @logger\assert @activeChannel, msgs.errors.noActiveChannel - return not @platforms or ({p,true for p in *@platforms})[@@platform], @@platform - - getChangelog: (versionRecord, minVer = 0) => - return "" unless "table" == type @changelog - maxVer = DependencyControl\parseVersion @version - minVer = DependencyControl\parseVersion minVer - - changelog = {} - for ver, entry in pairs @changelog - ver = DependencyControl\parseVersion ver - verStr = DependencyControl\getVersionString ver - if ver >= minVer and ver <= maxVer - changelog[#changelog+1] = {ver, verStr, entry} - - return "" if #changelog == 0 - table.sort changelog, (a,b) -> a[1]>b[1] - - msg = {msgs.changelog.header\format @name, DependencyControl\getVersionString(@version), @released or ""} - for chg in *changelog - chg[3] = {chg[3]} if type(chg[3]) ~= "table" - if #chg[3] > 0 - msg[#msg+1] = @logger\format msgs.changelog.verTemplate, 1, chg[2] - msg[#msg+1] = @logger\format(msgs.changelog.msgTemplate, 1, entry) for entry in *chg[3] - - return table.concat msg, "\n" - -class UpdateFeed extends Common - templateData = { - maxDepth: 7, - templates: { - feedName: {depth: 1, order: 1, key: "name" } - baseUrl: {depth: 1, order: 2, key: "baseUrl" } - feed: {depth: 1, order: 3, key: "knownFeeds", isHashTable: true } - namespace: {depth: 3, order: 1, parentKeys: {macros:true, modules:true} } - namespacePath: {depth: 3, order: 2, parentKeys: {macros:true, modules:true}, repl:"%.", to: "/" } - scriptName: {depth: 3, order: 3, key: "name" } - channel: {depth: 5, order: 1, parentKeys: {channels:true} } - version: {depth: 5, order: 2, key: "version" } - platform: {depth: 7, order: 1, key: "platform" } - fileName: {depth: 7, order: 2, key: "name" } - -- rolling templates - fileBaseUrl: {key: "fileBaseUrl", rolling: true } - } - sourceAt: {} - } - - msgs = { - trace: { - usingCached: "Using cached feed." - downloaded: "Downloaded feed to %s." - } - errors: { - 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)." - parse: "Error parsing feed." - } - } - - @defaultConfig = { - downloadPath: aegisub.decode_path "?temp/l0.#{@@__name}_feedCache" - dumpExpanded: false - } - @cache = {} - - fileBaseName = "l0.#{@@__name}_" - fileMatchTemplate = "l0.#{@@__name}_%x%x%x%x.*%.json" - feedsHaveBeenTrimmed = false - - -- precalculate some tables for the templater - templateData.rolling = {n, true for n,t in pairs templateData.templates when t.rolling} - templateData.sourceKeys = {t.key, t.depth for n,t in pairs templateData.templates when t.key} - with templateData - for i=1,.maxDepth - .sourceAt[i], j = {}, 1 - for name, tmpl in pairs .templates - if tmpl.depth==i and not tmpl.rolling - .sourceAt[i][j] = name - j += 1 - table.sort .sourceAt[i], (a,b) -> return .templates[a].order < .templates[b].order - - 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 - - -- 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"} - 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] - -- TODO: maybe also search all requirements for feed URLs - - fetch: (fileName) => - @fileName = fileName if fileName - - dl, err = @downloadManager\addDownload @url, @fileName - unless dl - return false, msgs.errors.downloadAdd\format @url, @fileName, err - - @downloadManager\waitForFinish -> true - if dl.error - return false, msgs.errors.downloadFailed\format @url, @fileName, dl.error - - @logger\trace msgs.trace.downloaded, @fileName - - handle, err = io.open @fileName - unless handle - return false, msgs.errors.cantOpen\format err - - decoded, data = pcall json.decode, handle\read "*a" - 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 - - 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 - @expand! - return @data - - expand: => - {:templates, :maxDepth, :sourceAt, :rolling, :sourceKeys} = templateData - vars, rvars = {}, {i, {} for i=0, maxDepth} - - expandTemplates = (val, depth, rOff=0) -> - return switch type val - 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] - val\gsub "@{(.-)}", (name) -> vars[name] or rvars[depth+rOff][name] - when "table" - {k, expandTemplates v, depth, rOff for k, v in pairs val} - else val - - - recurse = (obj, depth = 1, parentKey = "", upKey = "") -> - -- collect regular template variables first - for name in *sourceAt[depth] - with templates[name] - if not .key - -- template variables are not expanded if they are keys - vars[name] = parentKey if .parentKeys[upKey] - elseif .key and obj[.key] - -- expand other templates used in template variable - obj[.key] = expandTemplates obj[.key], depth - vars[name] = obj[.key] - vars[name] = vars[name]\gsub(.repl, .to) if .repl - - -- update rolling template variables last - for name,_ in pairs rolling - rvars[depth][name] = obj[templates[name].key] or rvars[depth-1][name] or "" - rvars[depth][name] = expandTemplates rvars[depth][name], depth, -1 - obj[templates[name].key] and= rvars[depth][name] - - -- expand variables in non-template strings and recurse tables - for k,v in pairs obj - if sourceKeys[k] ~= depth and not rolling[k] - switch type v - when "string" - obj[k] = expandTemplates obj[k], depth - when "table" - recurse v, depth+1, k, parentKey - -- invalidate template variables created at depth+1 - vars[name] = nil for name in *sourceAt[depth+1] - rvars[depth+1] = {} - - recurse @data - - if @dumpExpanded - handle = io.open @fileName\gsub(".json$", ".exp.json"), "w" - handle\write(json.encode @data)\close! - - return @data - - getScript: (namespace, scriptType, config, autoChannel) => - section = @@ScriptType.name.legacy[scriptType] - scriptData = @data[section][namespace] - return false unless scriptData - ScriptUpdateRecord namespace, scriptData, config, scriptType, autoChannel, @logger - - getMacro: (namespace, config, autoChannel) => - @getScript namespace, false, config, autoChannel - - getModule: (namespace, config, autoChannel) => - @getScript namespace, true, config, autoChannel \ No newline at end of file diff --git a/modules/l0/AegisubShims.moon b/modules/l0/AegisubShims.moon new file mode 100644 index 0000000..d7bb826 --- /dev/null +++ b/modules/l0/AegisubShims.moon @@ -0,0 +1,9 @@ +aegisub = require "l0.AegisubShims.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/l0/AegisubShims/aegisub.moon b/modules/l0/AegisubShims/aegisub.moon new file mode 100644 index 0000000..f135c55 --- /dev/null +++ b/modules/l0/AegisubShims/aegisub.moon @@ -0,0 +1,174 @@ +-- 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 + +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. +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) -> + 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. +-- @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/l0/DependencyControl.moon b/modules/l0/DependencyControl.moon new file mode 100644 index 0000000..5c00b21 --- /dev/null +++ b/modules/l0/DependencyControl.moon @@ -0,0 +1,90 @@ +MIN_MOONSCRIPT_VERSION = "0.3.0" + +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" +moonscript = require 'moonscript.version' +assert SemanticVersioning\check(moonscript.version, MIN_MOONSCRIPT_VERSION), + [[ DependencyControl requires Moonscript v%s or later to work, +however the Version %s provided by your Aegisub installation is outdated. +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" + +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" +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 + @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 + @SemanticVersioning = SemanticVersioning + +rec = DependencyControl{ + name: "DependencyControl", + version: "0.7.0", + description: "Provides script management and auto-updating for Aegisub macros and modules.", + author: "line0", + url: "http://github.com/TypesettingTools/DependencyControl", + moduleName: "l0.DependencyControl", + feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json", + { + -- 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}, + } +} +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 new file mode 100644 index 0000000..02d5fad --- /dev/null +++ b/modules/l0/DependencyControl/Common.moon @@ -0,0 +1,275 @@ +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. +-- 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 + + +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 + msgs = { + validateNamespace: { + badNamespace: "Namespace '%s' failed validation. Namespace rules: must contain 1+ single dots, but not start or end with a dot; all other characters must be in [A-Za-z0-9-_]." + } + } + -- Some terms are shared across components + @platform = "#{ffi.os}-#{ffi.arch}" + + @moduleName = "l0.DependencyControl" + + @terms = { + scriptType: { + singular: { "automation script", "module" } + plural: { "automation scripts", "modules" } + } + + isInstall: { + [true]: "installation" + [false]: "update" + } + + capitalize: (str) -> (str\sub 1, 1)\upper! .. str\sub 2 + } + + -- Common enums + @RecordType = { + Managed: 1 + Unmanaged: 2 + } + + @ScriptType = { + Automation: 1 + Module: 2 + name: { + legacy: { "macros", "modules" } + canonical: {"automation", "modules"} + } + } + + --- Validates a DependencyControl namespace string. + -- @param namespace string + -- @return boolean|nil + -- @return string|nil err + @validateNamespace = (namespace) -> + segments = [seg for seg in namespace\gmatch "[^%.]+"] + _, dotCount = namespace\gsub "%.", "" + if #segments >= 2 and dotCount == #segments - 1 and not namespace\match "[^-._%w]" + return true + return false, msgs.validateNamespace.badNamespace\format namespace + + @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 + + + --- Deep equality comparison. Tables compared recursively; other types use ==. + -- Circular references are handled. Metatables are included in the comparison. + -- @static + -- @param a + -- @param b + -- @treturn boolean + @equals = _equals + + --- Compares table items for equality, ignoring keys. + -- By default only numerical indexes are compared. + -- @static + -- @tparam table a + -- @tparam table b + -- @tparam[opt=true] boolean onlyNumKeys + -- @tparam[opt=false] boolean ignoreExtraAItems + -- @tparam[opt=false] boolean requireIdenticalItems + -- @treturn boolean + @itemsEqual = _itemsEqual + + --- Shallow-copies a table (no metatable). + -- @static + -- @param tbl table the table to copy + -- @return table the copied table + @copy = (tbl) -> {k, v for k, v in pairs tbl} + + --- Deep-copies a table recursively (no metatables). + -- @param tbl table the table to deep copy + -- @return table the deep-copied table + deepCopy = (tbl) -> {k, (type(v) == "table" and deepCopy(v) or v) for k, v in pairs tbl} + + --- Deep-copies a table recursively (no metatables). + -- @static + -- @param tbl table the table to deep copy + -- @return table the deep-copied table + @deepCopy = deepCopy + + --- 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 + + --- 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/ConfigHandler.moon b/modules/l0/DependencyControl/ConfigHandler.moon new file mode 100644 index 0000000..b5ff047 --- /dev/null +++ b/modules/l0/DependencyControl/ConfigHandler.moon @@ -0,0 +1,435 @@ +json = require "json" +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. +-- 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 + 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.]] + 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" + } + } + + -- make references to provided handlers weak to allow for gc + @handlers = setmetatable {}, {__mode: 'v'} + @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 + -- @param[opt] logger Logger + -- @param[opt=false] noLoad boolean + -- @return ConfigHandler|nil + -- @return string|nil err + @get = (filePath, logger = @logger, noLoad = false) => + return handler for path, handler in pairs @@handlers when path == filePath + + path, msg = fileOps.validateFullPath filePath, true + return nil, msgs.new.badPath\format filePath, msg unless path + + success, handler = pcall ConfigHandler, path, logger + unless success + return nil, msgs.get.failedCreate\format filePath, handler + + @@handlers[path] = handler + + unless noLoad + success, msg = handler\load! + return nil, msgs.get.failedLoad\format filePath, msg unless success + + return handler + + + --- Returns a ConfigView for the given file and hive path, creating a handler if needed. + -- @param filePath string + -- @param hivePath string|string[] + -- @param[opt] defaults table + -- @param[opt] logger Logger + -- @return ConfigView|nil + -- @return string|nil err + @getView = (filePath, hivePath, defaults, logger) => + handler, msg = @get filePath, logger + return nil, msgs.getView.failedHandler\format filePath, msg unless handler + + return handler\getView hivePath, defaults + + + --- 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 + return nil, file + + elseif not mode + @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, msg = io.open file, "r" + unless handle + @lock\release! if useLock + return nil, msgs.readFile.failedHandle\format msg + + data = handle\read "*a" + handle\close! + + @lock\release! if useLock + + success, res = pcall json.decode, data + unless success + -- 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 their work + @logger\debug msgs.readFile.jsonDecodeError, res + backup = @filePath .. ".corrupted" + fileOps.copy @filePath, backup + fileOps.remove @filePath, false, true + + @logger\warn msgs.readFile.configCorrupted, backup + return false, msgs.readFile.configCorrupted\format backup + + if "table" != type res + return nil, msgs.readFile.badJsonRoot\format type res + + return res + + + 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! + + @lock\release! unless haveLock + return true + + + 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 + + hive = {} + recurse path, hive, 1, config + return hive + + + traverseHive = (path, config, depth = #path) -> + for i, key in ipairs path + break if i > depth + switch type config + when "nil" + return false + when "table" + config = config[key] + else + return nil, msgs.traverseHive.badKey\format i, key, type config + + return config or false + + + 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 + + key = path[depth] + + if depth == #path + target[key] = source[key] + return true + + if target[key] != nil and "table" != type target[key] + return nil, msgs.mergeHive.badKey\format depth, key, type target[key] + + target[key] or= {} + return mergeHive path, source[key], target[key], depth + 1 + + + purgeHive = (path, config) -> + if #path == 0 + config[k] = nil for k, _ in pairs config + + for i = #path, 1, -1 + parent, msg = traverseHive path, config, i-1 + switch parent + when nil then return nil, msg + when false then continue + + parent[path[i]] = nil + break if hasNonPrivateFields parent + + return true + + + cleanHive = (path, config) -> + hive, msg = traverseHive path, config + return hive, msg if hive == nil + return true if hive == false -- path absent in file config; nothing to purge + + return false if hasNonPrivateFields hive + return purgeHive path, config + + + --- Deep-copies a value while skipping private keys prefixed with "_". + -- @param val any + -- @return any + -- copied from Aegisub util.moon, adjusted to skip private keys + @getSerializableCopy = (val) => + seen = {} + copy = (val) -> + return val if type(val) != 'table' + 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 val + + + --- Returns the config table at the given hive path, creating it if missing. + -- @param path string[] + -- @return table|nil hive + -- @return string|nil err + getHive: (path) => + hive, msg = traverseHive path, @config + switch hive + when nil + return nil, msg + when false + res, msg = mergeHive path, makeHive(path), @config + return nil, msg unless res + + hive, msg = traverseHive path, @config + unless hive + @logger\warn msgs.getHive.unexpected, path, @filePath + return nil, msgs.getHive.unexpected\format path, @filePath + + return hive + + + --- Returns views on the same handler whose hive paths overlap with targetView. + -- @param targetView ConfigView + -- @return ConfigView[]|nil + -- @return string|nil err + getOverlappingViews: (targetView) => + if targetView.__configHandler != @ + return nil, msgs.getOverlappingViews.differentHandler\format targetView.__configHandler.filePath, @filePath + + return for view, _ in pairs @views + continue if view == targetView or not targetView\isOverlappingView view + view + + + --- Creates and registers a ConfigView for the given hive path. + -- @param hivePath string|string[] + -- @param[opt] defaults table + -- @return ConfigView|nil + -- @return string|nil err + getView: (hivePath, defaults) => + success, view = pcall ConfigView, @, hivePath, defaults + + unless success + return nil, msgs.getView.failedView\format hivePath, @filePath, view + + @views[view] = true + return view + + + --- Reads the config file and refreshes the in-memory config and all (or specified) views. + -- @param[opt] views ConfigView|ConfigView[] + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + load: (views, waitLockTime) => + return nil, msgs.load.noFilePath unless @filePath + if type(views) == "table" and views.__class == ConfigView + views = {views} + + config, msg = readFile @, waitLockTime + return nil, msg if config == nil + + @logger\debug msgs.load.noFile, @filePath, msg unless config + -- config file may not yet exist or have been reset due to corruption + config or= {} + + if views == nil or @config == nil + @config = config + view\refresh! for view, _ in pairs @views + return true + + viewsToRefresh = {view, true for view in *views} + + for view in *views + hiveConfig, msg = traverseHive view.__hivePath, config + switch hiveConfig + when nil + return nil, msg + when false + mergeHive view.__hivePath, makeHive(view.__hivePath), @config + else mergeHive view.__hivePath, makeHive(view.__hivePath, hiveConfig), @config + + viewsToRefresh[v] or= true for v in *@getOverlappingViews view + + view\refresh! for view, _ in pairs viewsToRefresh + + return true + + + --- Writes the config file, merging only the specified views (or the full config if nil). + -- @param[opt] views ConfigView|ConfigView[] + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + save: (views, waitLockTime) => + return nil, msgs.save.noFile unless @filePath + if type(views) == "table" and views.__class == ConfigView + views = {views} + + -- get a lock to avoid concurrent config file access + lockState, msg = @lock\lock waitLockTime + if lockState != Lock.LockState.Held + return nil, msgs.save.failedLock\format msg + + -- read the config file + config, err = readFile @ + if config == nil + @lock\release! + return nil, msgs.save.failedRead\format @filePath, err + + @logger\trace msgs.save.fileCreate, @filePath unless config + config or= {} + + -- 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/l0/DependencyControl/ConfigView.moon b/modules/l0/DependencyControl/ConfigView.moon new file mode 100644 index 0000000..3b4f926 --- /dev/null +++ b/modules/l0/DependencyControl/ConfigView.moon @@ -0,0 +1,228 @@ +Common = require "l0.DependencyControl.Common" +local ConfigHandler + +--- A view into a hive (nested path) of a ConfigHandler's JSON config file. +-- Holds the proxy/defaults machinery and exposes @c / @config / @userConfig. +-- Multiple views on the same file are coordinated through their shared ConfigHandler. +-- @class ConfigView +class ConfigView + msgs = { + new: { + failedRetrieveHive: "Failed to retrieve hive %s from ConfigHandler: %s" + } + isOverlappingView: { + differentHandler: "Other view on config file '%s' does not belong to the same config handler as this view on config file '%s'." + } + } + + --- Returns a ConfigView for the given file and hive path, creating a handler if needed. + -- @param filePath string|boolean + -- @param hivePath string|string[] + -- @param[opt] defaults table + -- @param[opt] logger Logger + -- @param[opt=false] noLoad boolean + -- @return ConfigView|nil + -- @return string|nil err + @get = (filePath, hivePath, defaults, logger, noLoad = false) => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + + if filePath + handler, msg = ConfigHandler\get filePath, logger, noLoad + return nil, msg unless handler + return handler\getView hivePath, defaults + else + -- orphan view: in-memory only, no file backing (used for virtual modules) + handler = ConfigHandler nil, logger + return ConfigView handler, hivePath, defaults + + + --- Creates a view into a hive of the given ConfigHandler. + -- @param configHandler ConfigHandler|nil + -- @param hivePath string|string[] + -- @param[opt] defaults table + new: (configHandler, hivePath, defaults) => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + @__hivePath = "table" == type(hivePath) and hivePath or {hivePath} + @__configHandler = configHandler + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + @section = @__hivePath + -- compat: expose file path directly on the view + @file = configHandler and configHandler.filePath + + if configHandler + success, msg = @refresh! + configHandler.logger\assert @userConfig, msgs.new.failedRetrieveHive, hivePath, msg + else + @userConfig = {} -- orphan view: no file backing + + setDefaults @, defaults + @config = setmetatable {}, { + __index: (_, k) -> + if @userConfig[k] ~= nil + return @userConfig[k] + else return @defaults[k] + __newindex: (_, k, v) -> + @userConfig[k] = v + __len: (tbl) -> return 0 + __ipairs: (tbl) -> error "numerically indexed config hive keys are not supported" + __pairs: (tbl) -> + merged = Common.copy @defaults + merged[k] = v for k, v in pairs @userConfig + return next, merged + } + @c = @config -- shortcut + + + setDefaults = (defaults) => + @defaults = defaults and Common.deepCopy(defaults) or {} + -- rig defaults in a way that writing to contained tables deep-copies the whole default + -- into the user configuration and sets the requested property there + recurse = (tbl) -> + for k,v in pairs tbl + continue if type(v)~="table" or type(k)=="string" and k\match "^__" + -- replace every table reference with an empty proxy table + -- this ensures all writes to the table get intercepted + tbl[k] = setmetatable {__targetMethodKey: k, __parent: tbl, __targetTable: v}, { + -- make the original table the index of the proxy so that defaults can be read + __index: v + __len: (tbl) -> return #tbl.__targetTable + __newindex: (tbl, k, v) -> + upKeys, parent = {}, tbl.__parent + -- trace back to defaults entry, pick up the keys along the path + while parent.__parent + tbl = parent + upKeys[#upKeys+1] = tbl.__targetMethodKey + parent = tbl.__parent + + -- deep copy the whole defaults node into the user configuration + -- (util.deep_copy does not copy attached metatable references) + -- make sure we copy the actual table, not the proxy + @userConfig[tbl.__targetMethodKey] = Common.deepCopy @defaults[tbl.__targetMethodKey].__targetTable + -- finally perform requested write on userdata + tbl = @userConfig[tbl.__targetMethodKey] + for i = #upKeys-1, 1, -1 + tbl = tbl[upKeys[i]] + tbl[k] = v + __pairs: (tbl) -> return next, tbl.__targetTable + __ipairs: (tbl) -> + i, n, orgTbl = 0, #tbl.__targetTable, tbl.__targetTable + -> + i += 1 + return i, orgTbl[i] if i <= n + } + recurse tbl[k] + + recurse @defaults + + + --- Removes this view's hive from the config file. + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + delete: (waitLockTime) => + @userConfig, msg = @__configHandler\purgeHive @ + return nil, msg unless @userConfig + return @save waitLockTime + + + --- Copies values from a table or ConfigView into this view's user config. + -- @param[opt] tbl table|ConfigView + -- @param[opt] keys string[] + -- @param[opt] updateOnly boolean + -- @param[opt] skipSameLengthTables boolean + -- @return boolean changesMade + import: (tbl, keys, updateOnly, skipSameLengthTables) => + tbl = tbl.userConfig if tbl.__class == @@ + changesMade = false + keySet = {key, true for key in *keys} if keys + + for k, v in pairs tbl + continue if keys and not keySet[k] or @userConfig[k] == v + continue if updateOnly and @config[k] == nil + isTable = type(v) == "table" + if isTable and skipSameLengthTables and type(@userConfig[k]) == "table" and #v == #@userConfig[k] + continue + continue if type(k) == "string" and k\sub(1,1) == "_" + @userConfig[k] = ConfigHandler\getSerializableCopy v + changesMade = true + + return changesMade + + + --- Returns whether this view's hive overlaps with another view on the same handler. + -- @param otherView ConfigView + -- @return boolean|nil + -- @return string|nil err + isOverlappingView: (otherView) => + if @__configHandler != otherView.__configHandler + return nil, msgs.isOverlappingView.differentHandler\format otherView.__configHandler.filePath, + @__configHandler.filePath + + thisViewHivePathDepth, otherViewHivePathDepth = #@__hivePath, #otherView.__hivePath + + return true if thisViewHivePathDepth == 0 or otherViewHivePathDepth == 0 + + for i, key in ipairs @__hivePath + return false if key != otherView.__hivePath[i] + return true if i == thisViewHivePathDepth or i == otherViewHivePathDepth + + + --- Reloads only this view's hive from the config file. + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + load: (waitLockTime) => + return false unless @__configHandler and @__configHandler.filePath + @__configHandler\load @, waitLockTime + + + --- Refreshes this view's userConfig from the handler's in-memory config. + -- @return boolean|nil + -- @return string|nil err + refresh: => + @userConfig, msg = @__configHandler\getHive @__hivePath + return if @userConfig + true + else nil, msg + + + --- Writes this view's hive to the config file. + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + save: (waitLockTime) => + return false unless @__configHandler and @__configHandler.filePath + @__configHandler\save @, waitLockTime + + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + write: (waitLockTime) => @save waitLockTime + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + -- Attaches this view to a different config file path. + setFile: (filePath) => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + logger = @__configHandler and @__configHandler.logger + handler, msg = ConfigHandler\get filePath, logger, true -- noLoad: caller loads separately + return nil, msg unless handler + @__configHandler = handler + @file = handler.filePath + return true + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + -- Detaches this view from its config file (reverts to orphan/in-memory state). + unsetFile: => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + @__configHandler = ConfigHandler nil, @__configHandler and @__configHandler.logger + @file = nil + @userConfig = {} + return true + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + -- Returns a new ConfigView for a child hive of this view's handler. + getSectionHandler: (hivePath, defaults, noLoad) => + view, msg = @__configHandler\getView hivePath, defaults + return nil, msg unless view + view\load! unless noLoad + return view diff --git a/modules/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/Crypto.moon b/modules/l0/DependencyControl/Crypto.moon new file mode 100644 index 0000000..6afaced --- /dev/null +++ b/modules/l0/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/l0/DependencyControl/DownloadManager.moon b/modules/l0/DependencyControl/DownloadManager.moon new file mode 100644 index 0000000..971ca77 --- /dev/null +++ b/modules/l0/DependencyControl/DownloadManager.moon @@ -0,0 +1,89 @@ +-- 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" +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 + +return DownloadManager diff --git a/modules/l0/DependencyControl/Downloader.moon b/modules/l0/DependencyControl/Downloader.moon new file mode 100644 index 0000000..a5ff609 --- /dev/null +++ b/modules/l0/DependencyControl/Downloader.moon @@ -0,0 +1,545 @@ +-- 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) + +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'." + 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." + stalled: "Download stalled: no data received for %d seconds." +} + +-- 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 + 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 + 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 + driver.finish dl + dl\_cancel! + else + status = driver.step dl + if status == "more" + dl\_notifyProgress! + 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! + else + 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 + + -- 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(). +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_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); + 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 -- 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. + 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 + -- 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 + 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 + -- 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 + curl.curl_multi_add_handle multi, handle + + drain = -> + pending = ffi.new "int[1]" + while true + 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 = multiStackInfo.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 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); + ]] + + 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 -- 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]" + 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 = (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_SIZE + read = ffi.new "unsigned long[1]" + { + start: (dl) -> + 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 + outFileHandle\close! + return false, msgs.openUrlFailed\format dl.url + status = queryNumber request, HTTP_QUERY_STATUS_CODE + if status and status >= 400 + winInet.InternetCloseHandle request + outFileHandle\close! + return false, msgs.httpStatus\format status + 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_SIZE, read + n = tonumber read[0] + return "done" if n == 0 + dl._outFileHandle\write ffi.string buffer, n + dl.bytesReceived += n + "more" + + finish: (dl) -> + winInet.InternetCloseHandle dl._request if dl._request + dl._outFileHandle\close! if dl._outFileHandle + dl._request, dl._outFileHandle = nil + + shutdown: -> + winInet.InternetCloseHandle session + } + + defaultRunner = (manager) -> + multiplex manager, makeWinINetDriver manager.maxConnections + + 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 + +--- 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 + + -- 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 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 + + --- 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) + + FileOps.mkdir outfile, true, true + + @_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/l0/DependencyControl/Enum.moon b/modules/l0/DependencyControl/Enum.moon new file mode 100644 index 0000000..8f8da85 --- /dev/null +++ b/modules/l0/DependencyControl/Enum.moon @@ -0,0 +1,127 @@ +Logger = require "l0.DependencyControl.Logger" + +reservedKeys = { + "describe", + "elements" + "keys", + "name", + "test", + "values" +} + +reservedKeySet = {v, true for v in *reservedKeys} + +msgs = { + __index: { + invalidKeyAccess: "Cannot access invalid key '%s' on Enum '%s'" + } + __newindex: { + immutableError: "Cannot assign field '%s' to '%s' on immutable Enum '%s'." + } + new: { + valueAlreadyTaken: "Could not define '%s' in enum '%s': value %s is already taken by '%s'." + keyAlreadyDefined: "Cannot redefine key '%s' in enum '%s'." + noReservedKeys: "Key may not be any of the reserved words [#{table.concat reservedKeys, ', '}] or start with '__' (was '%s')." + missingOrInvalidName: "Missing or invalid Enum name (expected a string, got a '%s')." + } + describe: { + valueNotDefined: "Value '%s' is not defined in enum '%s'." + } + validate: { + argPrefix: "Argument %s: " + invalidValue: "%sInvalid value '%s' for enum '%s'." + } +} + +--- An immutable enumeration type with value/key reverse lookup. +-- @class Enum +class Enum + @logger = Logger fileBaseName: "DependencyControl.Enum" + @reservedKeys = reservedKeys + @isReservedKey = (k) => + return type(k) == "string" and (k\sub(1,2) == "__" or reservedKeySet[k]) or false + + + --- Creates an enum from a table of key/value pairs or a list of names. + -- @param name string + -- @param values table + -- @param[opt] logger Logger + new: (@name, values, @__logger = @@logger) => + @__logger\assert type(@name) == "string", msgs.new.missingOrInvalidName, Logger\describeType @name + @elements, @__valuesToKeys, @values, @keys = {}, {}, {}, {} + + for k, v in pairs values + -- we support lists as input, but we do not support numerical keys, which is sane + if "number" == type k + k, v = v, k + + @__logger\assert not @@isReservedKey(k), msgs.new.noReservedKeys, k + @__logger\assert @elements[k] == nil, msgs.new.keyAlreadyDefined, k, @name + @__logger\assert @__valuesToKeys[v] == nil, msgs.new.valueAlreadyTaken, k, @name, v, @__valuesToKeys[v] + + @elements[k], @__valuesToKeys[v] = v, k + table.insert @values, v + table.insert @keys, k + + meta = getmetatable @ + clsIdx = meta.__index + + setmetatable @, setmetatable { + __index: (k) => + if @elements[k] != nil + return @elements[k] + + v = switch type clsIdx + when "function" then clsIdx @, k + when "table" then clsIdx[k] + return v if v != nil + + @__logger\error msgs.__index.invalidKeyAccess, k, @name + + __newindex: (k, v) => + @__logger\error msgs.__newindex.immutableError, k, v, @name + }, clsIdx + + + --- Returns whether the given key is defined in this enum. + -- @param key string + -- @return boolean + -- @return any|nil value + test: (key) => + val = @elements[key] + return val != nil and true or false, val + + + --- Returns the key name(s) for one or more values. + -- @param values any + -- @param[opt] join string|boolean + -- @return string|string[]|nil + -- @return string|nil err + describe: (values, join = false) => + key = @__valuesToKeys[values] + if key != nil + return key + + if "table" != type values + return nil, msgs.describe.valueNotDefined\format values, @name + + keys = for v in *values + key = @__valuesToKeys[v] + if key == nil + join and '' or nil + else key + + return join and table.concat(keys, join == true and ', ' or join) or keys + + + --- Validates that a value is a member of this enum. + -- @param value any + -- @param[opt] argName string + -- @return boolean|nil + -- @return string|nil err + validate: (value, argName) => + if value == nil or @__valuesToKeys[value] == nil + prefix = argName != nil and msgs.validate.argPrefix\format(argName) or "" + return nil, msgs.validate.invalidValue\format prefix, value, @name + + return true diff --git a/modules/l0/DependencyControl/EventEmitter.moon b/modules/l0/DependencyControl/EventEmitter.moon new file mode 100644 index 0000000..833ee0c --- /dev/null +++ b/modules/l0/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] diff --git a/modules/l0/DependencyControl/FileOps.moon b/modules/l0/DependencyControl/FileOps.moon new file mode 100644 index 0000000..bf4a3d3 --- /dev/null +++ b/modules/l0/DependencyControl/FileOps.moon @@ -0,0 +1,702 @@ +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" +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 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 + msgs = { + generic: { + deletionRescheduled: "Another deletion attempt has been rescheduled for the next restart." + } + attributes: { + badPath: "Path failed verification: %s." + genericError: "Can't retrieve attributes: %s." + noAttribute: "Can't find attribute with name '%s'." + } + + createConfig: { + handlerFailed: "Couldn't create ConfigHandler for the FileOps configuration file: %s" + }, + createTempDir: { + failedCreate: "Failed to create temporary directory: %s" + } + mkdir: { + createError: "Error creating directory: %s." + otherExists: "Couldn't create directory because a %s of the same name is already present." + } + copy: { + 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" + }, + 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..." + renamedDeletionFailed: "The existing file was successfully renamed to '%s', but couldn't be deleted (%s).\n%s" + 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 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 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: { + 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." + } + 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." + } + 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: "Could not generate a valid full path from base path '%s' and namespaced sub-path '%s': %s." + } + 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." + notFullPath: "The specified path is not a valid full path." + missingExt: "The specified path is missing a file extension." + } + } + + 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" + }} + @pathSep = ffi.os == "Windows" and "\\" or "/" + @pathMatch = { + sep: ffi.os == "Windows" and "\\" or "/" + sepAll: ffi.os == "Windows" and "[\\/]" or "/" + invalidChars: '[<>:"|%?%*%z%c;]' + } + -- supported file hash algorithms, keyed by HashType value + HashType = Enum "FileOpsHashType", { SHA1: "sha1" } + @HashType = HashType + hashAlgorithms = { [HashType.SHA1]: Crypto.sha1 } + @logger = Logger! + + -- 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 + ConfigView or= require "#{constants.DEPCTRL_NAMESPACE}.ConfigView" + unless FileOps.config + 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 + + --- 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/#{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. + -- @param[opt] recurse boolean + -- @param[opt] reSchedule boolean + -- @return boolean|nil overallSuccess + -- @return table details + -- @return string|nil firstErr + remove: (paths, recurse, reSchedule) -> + config, configLoaded, overallSuccess, details, firstErr = nil, false, true, {} + paths = {paths} unless type(paths) == "table" + + for path in *paths + mode, path = FileOps.attributes path, "mode" + if mode + rmFunc = mode == "file" and os.remove or FileOps.rmdir + res, err = rmFunc path, recurse + unless res + firstErr or= err + unless reSchedule -- delete operation failed entirely + details[path] = {nil, err} + overallSuccess = nil + continue + + -- load the FileOps configuration file and reschedule deletions + unless configLoaded + 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} + overallSuccess = false + + -- delete operation succeeded + else details[path] = {true} + -- file not found or permission issue + else details[path] = {nil, path} + + config\write! if configLoaded + return overallSuccess, details, firstErr + + --- Replays removals previously scheduled by @{FileOps:remove}. + -- @param[opt] configDir string + -- @return boolean + -- @return string|nil err + runScheduledRemoval: (configDir) -> + config, 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 + FileOps.remove paths, true + config.c.toRemove = {} + 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, clobber ) -> + -- source check + mode, sourceFullPath, _, _, fileName = FileOps.attributes source, "mode" + switch mode + when "directory" + return false, msgs.copy.dirCopyUnsupported + when nil + return false, msgs.copy.genericError\format source, target, sourceFullPath + when false + return false, msgs.copy.missingSource\format source + + -- target check + checkTarget = (target) -> + mode, targetFullPath = FileOps.attributes target, "mode" + switch mode + when "file" + return false, msgs.writeFile.targetExists\format target unless clobber + when nil + return false, msgs.copy.genericError\format source, target, targetFullPath + when "directory" + target ..= "/#{fileName}" + return checkTarget target + return true, targetFullPath + + success, targetFullPath = checkTarget target + return false, targetFullPath unless success + + input, msg = io.open sourceFullPath, "rb" + unless input + return false, msgs.copy.openError\format "source", sourceFullPath, msg + + output, msg = io.open targetFullPath, "wb" + unless output + input\close! + return false, msgs.copy.openError\format "target", targetFullPath, msg + + success, msg = output\write input\read "*a" + input\close! + output\close! + + if success + return true + else + return false, msgs.copy.genericError\format sourceFullPath, targetFullPath, msg + + 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: (...) -> + 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. + -- @tparam string path + -- @return iterator + pathSegments: (path) -> path\gmatch "[^/\\]+" + + --- Moves a file to a target path, optionally replacing existing targets. + -- @param source string + -- @param target string + -- @param[opt] overwrite boolean + -- @return boolean success + -- @return string|nil err + move: (source, target, overwrite) -> + mode, err = FileOps.attributes target, "mode" + if mode == "file" + unless overwrite + return false, msgs.move.exists\format source, target, mode + FileOps.logger\trace msgs.move.overwritingFile, target + res, _, err = FileOps.remove target + unless res + -- can't remove old target file, probably in use or lack of permissions + -- try to rename and then delete it + FileOps.logger\debug msgs.move.inUseTryingRename, target + junkName = "#{target}.depCtrlRemoved" + -- There might be an old removed file we couldn't delete before + FileOps.remove junkName + res = os.rename target, junkName + unless res + return false, msgs.move.cantRemove\format target, err + -- rename succeeded, now clean up after ourselves + res, _, err = FileOps.remove junkName, false, true + unless res + FileOps.logger\debug msgs.move.renamedDeletionFailed, junkName, err, msgs.generic.deletionRescheduled + + elseif mode -- a directory (or something else) of the same name as the target file is already present + return false, msgs.move.exists\format source, target, mode + elseif mode == nil -- if retrieving the attributes of a file fails, something is probably wrong + return false, msgs.move.genericError\format source, target, err + + else -- target file not found, check directory + res, dirOrErr = FileOps.mkdir target, true, true + if res == nil + return false, msgs.move.createDirError\format source, target, dirOrErr + elseif res + 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 + unless res + -- renaming the file failed, could be because of a permission issue + -- but me might a well be trying to rename over file system boundaries on *nix + -- so we should try copy + remove before giving up + FileOps.logger\debug msgs.move.cantRenameTryingCopy, source, target, err + renErr, res, err = err, FileOps.copy source, target + unless res + return false, msgs.move.cantCopy\format source, target, err, renErr + res, details = FileOps.remove source, false, true -- TODO: also support directories/recursion, but also require copy to support it + + unless res + fileList = table.concat ["#{path}: #{res[2]}" for path, res in pairs details when not res[1]], "\n" + FileOps.logger\debug msgs.move.couldntRemoveFiles, fileList, msgs.generic.deletionRescheduled + + return true + + --- Reads and returns the full contents of a file. + -- @param path string|string[] path or path segments to the file to read + -- @return string? data the contents of the file, or nil if an error occurred + -- @return string? err an error message if an error occurred, or nil if the file was read successfully + readFile: (path) -> + mode, fullPath = FileOps.attributes path, "mode" + return nil, msgs.readFile.cantOpen\format path, fullPath unless mode + return nil, msgs.readFile.notAFile\format path, mode if mode != "file" + + handle, msg = io.open fullPath, "rb" + return nil, msgs.readFile.cantOpen\format fullPath, msg unless handle + + data, msg = handle\read "*a" + handle\close! + + if data + return data + else return nil, msgs.readFile.cantRead\format path, msg + + --- 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 + -- @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" + return nil, msgs.rmdir.notPath unless mode == "directory" + + if recurse + -- recursively remove contained files and directories + 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" + return nil, msgs.rmdir.couldntRemoveFiles\format fileList + + -- remove empty directory + success, err = lfs.rmdir path + unless success + return nil, msgs.rmdir.couldntRemoveDir\format err + + 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, 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 + return true, dir + elseif isFile and mode == "file" -- if the file already exists, so does the directory + return false, dir + elseif mode != "directory" -- a file of the same name as the target directory is already present + 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, false, lfs.currentdir! + unless fullPath + return nil, msgs.attributes.badPath\format dev + + 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 + 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, 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 "[\\/]+", 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 + if invChar + return nil, msgs.validateFullPath.invalidChars\format invChar + -- check if path is absolute + dev = FileOps.getPathRoot path + unless dev + -- 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 + 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!] + return nil, msgs.validateFullPath.reservedNames\format segmentWithoutExt + unless segment\match "[^%.%s]$" + 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 FileOps.pathSep, file} + return path, dev, dir, file + + --- Converts a base path and namespace into a namespaced filesystem path. + -- Dots in the namespace are converted to path separators when nested is true. + -- @param basePath string|string[] base path or path segments to the directory under which the namespaced path should be created + -- @param namespace string + -- @param ext string file extension (including dot) + -- @param[opt=true] nested boolean + -- @return string|nil path + -- @return string|nil err + getNamespacedPath: (basePath, namespace, ext, nested = true) -> + res, msg = Common.validateNamespace namespace + return nil, msg unless res + + fullBasePath, msg = FileOps.validateFullPath basePath + return nil, msgs.getNamespacedPath.badBasePath\format basePath, msg unless fullBasePath + + namespacePath = "#{nested and namespace\gsub("%.", FileOps.pathSep) or namespace}#{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/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/Lock.moon b/modules/l0/DependencyControl/Lock.moon new file mode 100644 index 0000000..40d56de --- /dev/null +++ b/modules/l0/DependencyControl/Lock.moon @@ -0,0 +1,139 @@ +mutex = require "BM.BadMutex" +Timer = require "l0.DependencyControl.Timer" +Logger = require "l0.DependencyControl.Logger" +Enum = require "l0.DependencyControl.Enum" + +DEFAULT_LOCK_WAIT_INTERVAL = 250 +DEFAULT_EXPIRY_DURATION = 5 * 60 +DEFAULT_HOLDER_NAME = "unknown" + +--- Cooperative mutex-based lock with a sqlite-compatible interface. +-- The namespace and resource parameters are accepted for interface compatibility +-- with the sqlite Lock but are not used for actual locking — the underlying +-- BadMutex is a single global mutex, so only one lock can be held at a time +-- regardless of namespace/resource. This is sufficient since no scripts write +-- to multiple config files concurrently. +-- @class Lock +class Lock + msgs = { + new: { + lockNotReleased: "Lock holder '%s' (%s) did not release its lock on resource '%s.%s' before discarding it, cleaning up..." + } + lock: { + trying: "Trying to get a lock on resource '%s.%s' for holder '%s' (%s). Timeout in %ims..." + failed: "Could not attain lock on resource '%s.%s' for holder '%s' (%s): %s" + heldByOther: "Lock on resource '%s.%s' is currently held, retrying in %ims..." + alreadyHeld: "'%s' (%s) is already holding the lock on resource '%s.%s'." + attained: "'%s' (%s) attained the lock on resource '%s.%s'." + timeout: "Gave up trying to attain a lock on resource '%s.%s' for holder '%s' (%s) after timeout was reached." + } + release: { + failed: "Could not release lock on resource '%s.%s' for '%s' (%s): %s" + notHeld: "lock is not currently held by this instance" + released: "'%s' (%s) released its lock on resource '%s.%s'." + } + } + + @logger = Logger fileBaseName: "DependencyControl.Lock" + + @LockState = Enum "LockState", { + Unknown: -1 + Unavailable: 0 + Available: 1 + Held: 2 + }, @logger + LockState or= @LockState + + @uuid = -> + -- https://gist.github.com/jrus/3197011 + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"\gsub "[xy]", (c) -> + v = c == "x" and math.random(0, 0xf) or math.random 8, 0xb + return "%x"\format v + + --- Creates a lock for the given resource. + -- @param args table + new: (args) => + {namespace: @namespace, resource: @resource, holderName: @holderName, logger: @logger, expiresAfter: @expiresAfter} = args + @logger or= @@logger + @expiresAfter or= DEFAULT_EXPIRY_DURATION + @holderName or= DEFAULT_HOLDER_NAME + @instanceId = @@uuid! + + -- mutable held-state shared with the GC canary (avoids capturing self) + held = {false} + @_held = held + + -- release any still-held lock when this object is garbage collected + -- the canary must not hold a reference to self, or it will never be collected + holderName, instanceId, namespace, resource, logger = @holderName, @instanceId, @namespace, @resource, @logger + canary = newproxy true + (getmetatable canary).__gc = -> + if held[1] + pcall logger.warn, logger, msgs.new.lockNotReleased, holderName, instanceId, namespace, resource + pcall -> + mutex.unlock! + held[1] = false + + meta = getmetatable @ + setmetatable @, { + __metatable: meta + __index: meta.__index + __canary: canary + } + + --- Returns the current lock state for this instance. + -- Returns Held if this instance holds the lock, Unknown otherwise + -- (the global mutex cannot be queried without attempting to acquire it). + -- @return number LockState + getState: => + return if @_held[1] + @@LockState.Held + else + @@LockState.Unknown + + --- Attempts to acquire the lock, waiting up to timeout milliseconds. + -- @param[opt=math.huge] timeout number + -- @param[opt=250] lockWaitInterval number + -- @return number LockState + -- @return number timePassed + lock: (timeout = math.huge, lockWaitInterval = DEFAULT_LOCK_WAIT_INTERVAL) => + timePassed = 0 + while timeout == math.huge or timeout >= timePassed + @logger\trace msgs.lock.trying, @namespace, @resource, @holderName, @instanceId, + timeout == math.huge and math.huge or timeout - timePassed + + state = @getState! + switch state + when @@LockState.Held + @logger\trace msgs.lock.alreadyHeld, @holderName, @instanceId, @namespace, @resource + return @@LockState.Held, timePassed + + else -- Unknown: attempt to acquire + if mutex.tryLock! + @_held[1] = true + @logger\trace msgs.lock.attained, @holderName, @instanceId, @namespace, @resource + return @@LockState.Held, timePassed + + @logger\trace msgs.lock.heldByOther, @namespace, @resource, lockWaitInterval + Timer.sleep lockWaitInterval unless timeout == 0 + timePassed += lockWaitInterval + + @logger\trace msgs.lock.timeout, @namespace, @resource, @holderName, @instanceId + return @@LockState.Unavailable, timePassed + + --- Attempts to acquire the lock without waiting. + -- @return number LockState + -- @return number timePassed + tryLock: => + return @lock 0 + + --- Releases the lock held by this instance. + -- @return boolean|nil + -- @return string|nil err + release: => + unless @_held[1] + return nil, msgs.release.failed\format @namespace, @resource, @holderName, @instanceId, msgs.release.notHeld + mutex.unlock! + @_held[1] = false + @logger\trace msgs.release.released, @holderName, @instanceId, @namespace, @resource + return true, @@LockState.Available diff --git a/modules/DependencyControl/Logger.moon b/modules/l0/DependencyControl/Logger.moon similarity index 73% rename from modules/DependencyControl/Logger.moon rename to modules/l0/DependencyControl/Logger.moon index 17a5abf..a4ab943 100644 --- a/modules/DependencyControl/Logger.moon +++ b/modules/l0/DependencyControl/Logger.moon @@ -1,6 +1,8 @@ -PreciseTimer = require "PT.PreciseTimer" +Timer = require "l0.DependencyControl.Timer" lfs = require "lfs" +--- Structured logger that writes to Aegisub's log window and optional log files. +-- @class Logger class Logger levels = {"fatal", "error", "warning", "hint", "debug", "trace"} defaultLevel: 2 @@ -18,7 +20,7 @@ class Logger indentStr: "—" maxFiles: 200, maxAge: 604800, maxSize:10*(10^6) - timer, seeded = PreciseTimer!, false + timer, seeded = Timer!, false new: (args) => if args @@ -41,6 +43,14 @@ class Logger @fileName = @fileTemplate\format aegisub.decode_path(@logDir), os.date("%Y-%m-%d-%H-%M-%S"), math.random(0, 16^4-1), @fileBaseName, @fileSubName + --- Writes a log message with explicit rendering options. + -- @param[opt] level number + -- @param[opt] msg string|table + -- @param[opt=true] insertLineFeed boolean + -- @param[opt] prefix string + -- @param[opt] indent number + -- @param[opt] ... any + -- @return boolean logEx: (level = @defaultLevel, msg = "", insertLineFeed = true, prefix = @prefix, indent = @indent, ...) => return false if msg == "" @@ -51,7 +61,7 @@ class Logger msg = if @lastHadLineFeed @format msg, indent, ... elseif 0 < select "#", ... - msg\format ... + (tostring msg)\format ... show = aegisub.log and @toWindow if @toFile and level <= @maxToFileLevel @@ -73,7 +83,7 @@ class Logger msg = table.concat msg, "\n" if 0 < select "#", ... - msg = msg\format ... + msg = (tostring msg)\format ... return msg unless indent>0 @@ -96,10 +106,23 @@ class Logger debug: (...) => @log 4, ... trace: (...) => @log 5, ... + --- Logs an error message when the given condition is falsy. + -- @param cond any + -- @param[opt] ... any + -- @return any assert: (cond, ...) => if not cond @log 1, ... - else return cond + else return cond, ... + + --- Logs an error message when the given condition is nil. + -- @param cond any + -- @param[opt] ... any + -- @return any + assertNotNil: (cond, ...) => + if cond == nil + @log 1, ... + else return cond, ... progress: (progress=false, msg = "", ...) => if @progressStep and not progress @@ -115,10 +138,20 @@ class Logger @progressStep = step -- taken from https://github.com/TypesettingCartel/Aegisub-Motion/blob/master/src/Log.moon - dump: ( item, ignore, level = @defaultLevel ) => - @log level, @dumpToString item, ignore - - dumpToString: ( item, ignore ) => + --- Logs a table dump (or scalar value) at the specified level. + -- @param item any + -- @param[opt] ignore any + -- @param[opt] level number + -- @param[opt] maxDepth number + dump: ( item, ignore, level = @defaultLevel, maxDepth ) => + @log level, @dumpToString item, ignore, maxDepth + + --- Converts a table dump (or scalar value) to a readable string. + -- @param item any + -- @param[opt] ignore any + -- @param[opt] maxDepth number + -- @return string + dumpToString: ( item, ignore, maxDepth ) => if "table" != type item return tostring item @@ -126,7 +159,14 @@ class Logger result = { "{ @#{tablecount}" } seen = { [item]: tablecount } - recurse = ( item, space ) -> + recurse = ( item, space, depth = 0 ) -> + if maxDepth and depth > maxDepth + count += 1 + result[count] = space .. "<...>" + return + + depth += 1 + for key, value in pairs item unless key == ignore if "number" == type key @@ -137,7 +177,7 @@ class Logger seen[value] = tablecount count += 1 result[count] = space .. "#{key}: { @#{tablecount}" - recurse value, space .. " " + recurse value, space .. " ", depth count += 1 result[count] = space .. "}" else @@ -165,12 +205,15 @@ class Logger files, totalSize, deletedSize, now, f = {}, 0, 0, os.time!, 0 dir = aegisub.decode_path @logDir - lfs.chdir dir + -- 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 - 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 @@ -179,7 +222,15 @@ 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 + + @describeType = (val) => + _type = type val + return _type unless _type == "table" + + return if val.__class + "#{val.__class.__name} object" + else _type diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/l0/DependencyControl/ModuleLoader.moon similarity index 80% rename from modules/DependencyControl/ModuleLoader.moon rename to modules/l0/DependencyControl/ModuleLoader.moon index e1f6738..76c9c5c 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/l0/DependencyControl/ModuleLoader.moon @@ -1,172 +1,185 @@ --- Note: this is a private API intended to be exclusively for internal DependenyControl use --- Everyting in this class can and will change without any prior notice --- and calling any method is guaranteed to interfere with DepdencyControl operation - -class ModuleLoader - msgs = { - checkOptionalModules: { - downloadHint: "Please download the modules in question manually, put them in your %s folder and reload your automation scripts." - missing: "Error: a %s feature you're trying to use requires additional modules that were not found on your system:\n%s\n%s" - } - formatVersionErrorTemplate: { - missing: "— %s %s%s\n—— Reason: %s" - outdated: "— %s (Installed: v%s; Required: v%s)%s\n—— Reason: %s" - } - loadModules: { - missing: "Error: one or more of the modules required by %s could not be found on your system:\n%s\n%s" - missingRecord: "Error: module '%s' is missing a version record." - moduleError: "Error in required module %s:\n%s" - outdated: [[Error: one or more of the modules required by %s are outdated on your system: -%s\nPlease update the modules in question manually and reload your automation scripts.]] - } - } - - @formatVersionErrorTemplate = (name, reqVersion, url, reason, ref) => - url = url and ": #{url}" or "" - if ref - version = @@parseVersion ref.version - return msgs.formatVersionErrorTemplate.outdated\format name, version, reqVersion, url, reason - else - reqVersion = reqVersion and " (v#{reqVersion})" or "" - return msgs.formatVersionErrorTemplate.missing\format name, reqVersion, url, reason - - @createDummyRef = => - return nil if @scriptType != @@ScriptType.Module - -- global module registry allows for circular dependencies: - -- set a dummy reference to this module since this module is not ready - -- when the other one tries to load it (and vice versa) - export LOADED_MODULES = {} unless LOADED_MODULES - unless LOADED_MODULES[@namespace] - @ref = {} - LOADED_MODULES[@namespace] = setmetatable {__depCtrlDummy: true, version: @}, @ref - return true - return false - - @removeDummyRef = => - return nil if @scriptType != @@ScriptType.Module - if LOADED_MODULES[@namespace] and LOADED_MODULES[@namespace].__depCtrlDummy - LOADED_MODULES[@namespace] = nil - return true - return false - - @loadModule = (mdl, usePrivate, reload) => - runInitializer = (ref) -> - return unless type(ref) == "table" and ref.__depCtrlInit - -- Note to future self: don't change this to a class check! When DepCtrl self-updates - -- any managed module initialized before will still use the same instance - if type(ref.version) != "table" or ref.version.__name != @@__name - ref.__depCtrlInit @@ - - with mdl - ._missing, ._error = nil - - moduleName = usePrivate and "#{@namespace}.#{mdl.moduleName}" or .moduleName - name = "#{mdl.name or mdl.moduleName}#{usePrivate and ' (Private Copy)' or ''}" - - if .outdated or reload - -- clear old references - package.loaded[moduleName], LOADED_MODULES[moduleName] = nil - - elseif ._ref = LOADED_MODULES[moduleName] - -- module is already loaded, however it may or may not have been loaded by DepCtrl - -- so we have to call any DepCtrl initializer if it hasn't been called yet - runInitializer ._ref - return ._ref - - loaded, res = xpcall require, debug.traceback, moduleName - unless loaded - LOADED_MODULES[moduleName] = nil - res or= "unknown error" - ._missing = res\match "module '.+' not found:" - ._error = res unless ._missing - return nil - - -- set new references - if reload and ._ref and ._ref.__depCtrlDummy - setmetatable ._ref, res - ._ref, LOADED_MODULES[moduleName] = res, res - - -- run DepCtrl initializer if one was specified - runInitializer res - - return mdl._ref -- having this in the with block breaks moonscript - - @loadModules = (modules, addFeeds = {@feed}, skip = @moduleName and {[@moduleName]: true} or {}) => - for mdl in *modules - continue if skip[mdl] - with mdl - ._ref, ._updated, ._missing, ._outdated, ._reason, ._error = nil - - -- try to load private copies of required modules first - ModuleLoader.loadModule @, mdl, true - ModuleLoader.loadModule @, mdl unless ._ref - - -- try to fetch and load a missing module from the web - if ._missing - record = @@{moduleName:.moduleName, name:.name or .moduleName, - version:-1, url:.url, feed:.feed, virtual:true} - ._ref, code, extErr = @@updater\require record, .version, addFeeds, .optional - if ._ref or .optional - ._updated, ._missing = true, false - else - ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, true, extErr - -- nuke dummy reference for circular dependencies - LOADED_MODULES[.moduleName] = nil - - -- check if the version requirements are satisfied - -- which is guaranteed for modules updated with \require, so we don't need to check again - if .version and ._ref and not ._updated - record = ._ref.version - unless record - ._error = msgs.loadModules.missingRecord\format .moduleName - continue - - if type(record) != "table" or record.__class != @@ - record = @@ moduleName: .moduleName, version: record, recordType: @@RecordType.Unmanaged - - -- force an update for outdated modules - if not record\checkVersion .version - ref, code, extErr = @@updater\require record, .version, addFeeds - if ref - ._ref = ref - elseif not .optional - ._outdated = true - ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, false, extErr - else - -- perform regular update check if we can get a lock without waiting - -- right now we don't care about the result and don't reload the module - -- so the update will not be effective until the user restarts Aegisub - -- or reloads the script - @@updater\scheduleUpdate record - - missing, outdated, moduleError = {}, {}, {} - for mdl in *modules - with mdl - name = .name or .moduleName - if ._missing - missing[#missing+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason - elseif ._outdated - outdated[#outdated+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason, ._ref - elseif ._error - moduleError[#moduleError+1] = msgs.loadModules.moduleError\format name, ._error - - errorMsg = {} - if #moduleError > 0 - errorMsg[1] = table.concat moduleError, "\n" - if #outdated > 0 - errorMsg[#errorMsg+1] = msgs.loadModules.outdated\format @name, table.concat outdated, "\n" - if #missing > 0 - errorMsg[#errorMsg+1] = msgs.loadModules.missing\format @name, table.concat(missing, "\n"), downloadHint - - return #errorMsg == 0, table.concat(errorMsg, "\n\n") - - @checkOptionalModules = (modules) => - modules = type(modules)=="string" and {[modules]:true} or {mdl,true for mdl in *modules} - missing = [ModuleLoader.formatVersionErrorTemplate @, mdl.moduleName, mdl.version, msl.url, - mdl._reason for mdl in *@requiredModules when mdl.optional and mdl._missing and modules[mdl.name]] - - if #missing>0 - downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules - errorMsg = msgs.checkOptionalModules.missing\format @name, table.concat(missing, "\n"), downloadHint - return false, errorMsg - return true \ No newline at end of file +-- Note: this is a private API intended to be exclusively for internal DependenyControl use +-- Everything in this class can and will change without any prior notice +-- and calling any method is guaranteed to interfere with DependencyControl operation + +constants = require "l0.DependencyControl.Constants" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" +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 +class ModuleLoader + msgs = { + checkOptionalModules: { + downloadHint: "Please download the modules in question manually, put them in your %s folder and reload your automation scripts." + missing: "Error: a %s feature you're trying to use requires additional modules that were not found on your system:\n%s\n%s" + } + formatVersionErrorTemplate: { + missing: "— %s %s%s\n—— Reason: %s" + outdated: "— %s (Installed: v%s; Required: v%s)%s\n—— Reason: %s" + } + loadModules: { + missing: "Error: one or more of the modules required by %s could not be found on your system:\n%s\n%s" + missingRecord: "Error: module '%s' is missing a version record." + moduleError: "Error in required module %s:\n%s" + outdated: [[Error: one or more of the modules required by %s are outdated on your system: +%s\nPlease update the modules in question manually and reload your automation scripts.]] + } + } + + @formatVersionErrorTemplate = (name, reqVersion, url, reason, ref) => + url = url and ": #{url}" or "" + if ref + -- unmanaged records have refs whose .version is a string instead of a DepCtrl record + version = SemanticVersioning\toString type(ref.version) == "table" and ref.version.version or ref.version + return msgs.formatVersionErrorTemplate.outdated\format name, version, reqVersion, url, reason + else + reqVersion = reqVersion and " (v#{reqVersion})" or "" + return msgs.formatVersionErrorTemplate.missing\format name, reqVersion, url, reason + + @createDummyRef = => + return nil if @scriptType != @@ScriptType.Module + -- global module registry allows for circular dependencies: + -- set a dummy reference to this module since this module is not ready + -- when the other one tries to load it (and vice versa) + export LOADED_MODULES = {} unless LOADED_MODULES + unless LOADED_MODULES[@namespace] + @ref = {} + LOADED_MODULES[@namespace] = setmetatable {[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][DEPCTRL_DUMMY_MODULE_MARKER] + LOADED_MODULES[@namespace] = nil + return true + return false + + @loadModule = (mdl, usePrivate, reload) => + 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 + ModuleProvider.runInitializer ._ref, @@ + return ._ref + + loaded, res = xpcall require, debug.traceback, moduleName + unless loaded + LOADED_MODULES[moduleName] = nil + res or= "unknown error" + ._missing = nil != res\find "module '#{moduleName}' not found:", nil, true + ._error = res unless ._missing + return nil + + -- set new references + if reload and ._ref and ._ref[DEPCTRL_DUMMY_MODULE_MARKER] + setmetatable ._ref, res + ._ref, LOADED_MODULES[moduleName] = res, res + + -- run DepCtrl initializer if one was specified + ModuleProvider.runInitializer res, @@ + + return mdl._ref -- having this in the with block breaks moonscript + + --- Loads required modules, updates missing/outdated ones, and validates version constraints. + -- @param modules table[] + -- @param[opt] addFeeds string[] + -- @param[opt] skip table + -- @return boolean + -- @return string err + @loadModules = (modules, addFeeds = {@feed}, skip = @moduleName and {[@moduleName]: true} or {}) => + for mdl in *modules + continue if skip[mdl.moduleName] + with mdl + ._ref, ._updated, ._missing, ._outdated, ._reason, ._error = nil + + -- try to load private copies of required modules first + ModuleLoader.loadModule @, mdl, true + ModuleLoader.loadModule @, mdl unless ._ref + + -- try to fetch and load a missing module from the web + if ._missing + record = @@{moduleName:.moduleName, name:.name or .moduleName, + version:-1, url:.url, feed:.feed, virtual:true} + ._ref, code, extErr = @@updater\require record, .version, addFeeds, .optional + if ._ref or .optional + ._updated, ._missing = true, false + else + ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, true, extErr + -- nuke dummy reference for circular dependencies + LOADED_MODULES[.moduleName] = nil + + -- check if the version requirements are satisfied + -- which is guaranteed for modules updated with \require, so we don't need to check again + if .version and ._ref and not ._updated + record = ._ref.version + unless record + ._error = msgs.loadModules.missingRecord\format .moduleName + continue + + if type(record) != "table" or record.__class != @@ + record = @@ moduleName: .moduleName, version: record, recordType: @@RecordType.Unmanaged + + -- force an update for outdated modules + if not record\checkVersion .version + ref, code, extErr = @@updater\require record, .version, addFeeds + if ref + ._ref = ref + elseif not .optional + ._outdated = true + ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, false, extErr + else + -- perform regular update check if we can get a lock without waiting + -- right now we don't care about the result and don't reload the module + -- so the update will not be effective until the user restarts Aegisub + -- or reloads the script + @@updater\scheduleUpdate record + + missing, outdated, moduleError = {}, {}, {} + for mdl in *modules + with mdl + name = .name or .moduleName + if ._missing + missing[#missing+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason + elseif ._outdated + outdated[#outdated+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason, ._ref + elseif ._error + moduleError[#moduleError+1] = msgs.loadModules.moduleError\format name, ._error + + errorMsg = {} + if #moduleError > 0 + errorMsg[1] = table.concat moduleError, "\n" + if #outdated > 0 + errorMsg[#errorMsg+1] = msgs.loadModules.outdated\format @name, table.concat outdated, "\n" + if #missing > 0 + downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules + errorMsg[#errorMsg+1] = msgs.loadModules.missing\format @name, table.concat(missing, "\n"), downloadHint + + return #errorMsg == 0, table.concat(errorMsg, "\n\n") + + --- Validates optional module availability for the requested feature set. + -- @param modules string|string[] + -- @return boolean + -- @return string|nil err + @checkOptionalModules = (modules) => + modules = type(modules)=="string" and {[modules]:true} or {mdl,true for mdl in *modules} + missing = [ModuleLoader.formatVersionErrorTemplate @, mdl.moduleName, mdl.version, mdl.url, + mdl._reason for mdl in *@requiredModules when mdl.optional and mdl._missing and modules[mdl.name]] + + if #missing>0 + downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules + errorMsg = msgs.checkOptionalModules.missing\format @name, table.concat(missing, "\n"), downloadHint + return false, errorMsg + return true diff --git a/modules/l0/DependencyControl/ModuleProvider.moon b/modules/l0/DependencyControl/ModuleProvider.moon new file mode 100644 index 0000000..0baf68c --- /dev/null +++ b/modules/l0/DependencyControl/ModuleProvider.moon @@ -0,0 +1,90 @@ +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. +-- +-- 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 + +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 + state = { providers: {}, installed: false } + _G[GLOBAL_KEY] = state + +-- 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[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[DEPCTRL_MODULE_INIT_HOOK_NAME] 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[constants.DEPCTRL_NAMESPACE] + 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 + -> 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 + -- @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/l0/DependencyControl/Record.moon similarity index 61% rename from modules/DependencyControl/Record.moon rename to modules/l0/DependencyControl/Record.moon index 5293321..d6e2730 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/l0/DependencyControl/Record.moon @@ -1,315 +1,426 @@ -json = require "json" -lfs = require "lfs" -re = require "aegisub.re" - -Common = require "l0.DependencyControl.Common" -Logger = require "l0.DependencyControl.Logger" -ConfigHandler = require "l0.DependencyControl.ConfigHandler" -FileOps = require "l0.DependencyControl.FileOps" -Updater = require "l0.DependencyControl.Updater" -ModuleLoader = require "l0.DependencyControl.ModuleLoader" -SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" - -class Record extends Common - namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" - - msgs = { - new: { - badRecordError: "Error: Bad #{@@__name} record (%s)." - badRecord: { - noUnmanagedMacros: "Creating unmanaged version records for macros is not allowed" - missingNamespace: "No namespace defined" - badVersion: "Couldn't parse version number: %s" - badNamespace: "Namespace '%s' failed validation. Namespace rules: must contain 1+ single dots, but not start or end with a dot; all other characters must be in [A-Za-z0-9-_]." - badModuleTable: "Invalid required module table #%d (%s)." - } - } - uninstall: { - noVirtualOrUnmanaged: "Can't uninstall %s %s '%s'. (Only installed scripts managed by #{@@__name} can be uninstalled)." - } - writeConfig: { - error: "An error occured while writing the #{@@__name} config file: %s" - writing: "Writing updated %s data to config file..." - } - } - - @depConf = { - file: aegisub.decode_path "?user/config/l0.#{@@__name}.json", - scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", -- REMOVE - "requiredModules", "version", "unmanaged"}, - globalDefaults: {updaterEnabled:true, updateInterval:302400, traceLevel:3, extraFeeds:{}, - tryAllFeeds:false, dumpFeeds:true, configDir:"?user/config", - logMaxFiles: 200, logMaxAge: 604800, logMaxSize:10*(10^6), - updateWaitTimeout: 60, updateOrphanTimeout: 600, - logDir: "?user/log", writeLogs: true} - } - - init = => - FileOps.mkdir @depConf.file, true - @loadConfig! - @logger = Logger { fileBaseName: "DepCtrl", fileSubName: script_namespace, prefix: "[#{@@__name}] ", - toFile: @config.c.writeLogs, defaultLevel: @config.c.traceLevel, - maxAge: @config.c.logMaxAge,maxSize: @config.c.logMaxSize, maxFiles: @config.c.logMaxFiles, - logDir: @config.c.logDir } - - @updater = Updater script_namespace, @config, @logger - @configDir = @config.c.configDir - - FileOps.mkdir aegisub.decode_path @configDir - logsHaveBeenTrimmed or= @logger\trimFiles! - FileOps.runScheduledRemoval @configDir - - - new: (args) => - init Record unless @@logger - - -- defaults - args[k] = v for k, v in pairs { - readGlobalScriptVars: true - saveRecordToConfig: true - } when args[k] == nil - - {@requiredModules, moduleName:@moduleName, configFile:configFile, virtual:@virtual, :name, - description:@description, url:@url, feed:@feed, recordType:@recordType, :namespace, - author:@author, :version, configFile:@configFile, - :readGlobalScriptVars, :saveRecordToConfig} = args - - @recordType or= @@RecordType.Managed - -- also support name key (as used in configuration) for required modules - @requiredModules or= args.requiredModules - - if @moduleName - @namespace = @moduleName - @name = name or @moduleName - @scriptType = @@ScriptType.Module - ModuleLoader.createDummyRef @ unless @virtual or @recordType == @@RecordType.Unmanaged - - else - if @virtual or not readGlobalScriptVars - @name = name or namespace - @namespace = namespace - version or= 0 - else - @name = name or script_name - @description or= script_description - @author or= script_author - version or= script_version - - @namespace = namespace or script_namespace - assert @recordType == @@RecordType.Managed, msgs.new.badRecordError\format msgs.new.badRecord.noUnmanagedMacros - assert @namespace, msgs.new.badRecordError\format msgs.new.badRecord.missingNamespace - @scriptType = @@ScriptType.Automation - - -- if the hosting macro doesn't have a namespace defined, define it for - -- the first DepCtrled module loaded by the macro or its required modules - unless script_namespace - export script_namespace = @namespace - - -- non-depctrl record don't need to conform to namespace rules - assert @virtual or @recordType == @@RecordType.Unmanaged or @validateNamespace!, - msgs.new.badRecord.badNamespace\format @namespace - - @configFile = configFile or "#{@namespace}.json" - @automationDir = @@automationDir[@scriptType] - @testDir = @@testDir[@scriptType] - @version, err = @@parseVersion version - assert @version, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err - - @requiredModules or= {} - -- normalize short format module tables - for i, mdl in pairs @requiredModules - switch type mdl - when "table" - mdl.moduleName or= mdl[1] - mdl[1] = nil - when "string" - @requiredModules[i] = {moduleName: mdl} - else error msgs.new.badRecordError\format msgs.new.badRecord.badModuleTable\format i, tostring mdl - - shouldWriteConfig = @loadConfig! - - -- write config file if contents are missing or are out of sync with the script version record - -- ramp up the random wait time on first initialization (many scripts may want to write configuration data) - -- we can't really profit from write concerting here because we don't know which module loads last - @writeConfig if shouldWriteConfig and saveRecordToConfig - - checkOptionalModules: ModuleLoader.checkOptionalModules - - -- loads the DependencyControl global configuration - @loadConfig = => - if @config - @config\load! - else @config = ConfigHandler @depConf.file, @depConf.globalDefaults, {"config"}, nil, @logger - - -- loads the script configuration - loadConfig: (importRecord = false) => - -- virtual modules are not yet present on the user's system and have no persistent configuration - @config or= ConfigHandler not @virtual and @@depConf.file, {}, - { @@ScriptType.name.legacy[@scriptType], @namespace }, true, @@logger - - -- import and overwrites version record from the configuration - if importRecord - -- check if a module that was previously virtual was installed in the meantime - -- TODO: prevent issues caused by orphaned config entries - haveConfig = false - if @virtual - @config\setFile @@depConf.file - if @config\load! - haveConfig, @virtual = true, false - else @config\unsetFile! - else - haveConfig = @config\load! - - -- only need to refresh data if the record was changed by an update - if haveConfig - @[key] = @config.c[key] for key in *@@depConf.scriptFields - - elseif not @virtual - -- copy script information to the config - @config\load! - shouldWriteConfig = @config\import @, @@depConf.scriptFields, false, true - return shouldWriteConfig - - return false - - writeConfig: => - unless @virtual or @config.file - @config\setFile @@depConf.file - - @@logger\trace msgs.writeConfig.writing, @@terms.scriptType.singular[@scriptType] - @config\import @, @@depConf.scriptFields, false, true - success, errMsg = @config\write false - - assert success, msgs.writeConfig.error\format errMsg - - - @parseVersion = SemanticVersioning.parse - - - @getVersionString = SemanticVersioning.toString - - - getConfigFileName: () => - return aegisub.decode_path "#{@@configDir}/#{@configFile}" - - getConfigHandler: (defaults, section, noLoad) => - return ConfigHandler @getConfigFileName!, defaults, section, noLoad - - getLogger: (args = {}) => - args.fileBaseName or= @namespace - args.toFile = @config.c.logToFile if args.toFile == nil - args.defaultLevel or= @config.c.logLevel - args.prefix or= @moduleName and "[#{@name}]" - - return Logger args - - checkVersion: (value, precision = "patch") => - if type(value) == "table" and value.__class == @@ - value = value.version - return SemanticVersioning\check @version, value - - - getSubmodules: => - return nil if @virtual or @recordType == @@RecordType.Unmanaged or @scriptType != @@ScriptType.Module - mdlConfig = @@config\getSectionHandler @@ScriptType.name.legacy[@@ScriptType.Module] - pattern = "^#{@namespace}."\gsub "%.", "%%." - return [mdl for mdl, _ in pairs mdlConfig.c when mdl\match pattern], mdlConfig - - requireModules: (modules = @requiredModules, addFeeds = {@feed}) => - success, err = ModuleLoader.loadModules @, modules, addFeeds - @@updater\releaseLock! - unless success - -- if we failed loading our required modules - -- then that means we also failed to load - LOADED_MODULES[@namespace] = nil - @@logger\error err - return unpack [mdl._ref for mdl in *modules] - - registerTests: (...) => - -- load external tests - haveTests, tests = pcall require, "DepUnit.#{@@ScriptType.name.legacy[@scriptType]}.#{@namespace}" - - if haveTests and not @testsLoaded - @tests, tests.name = tests, @name - modules = table.pack @requireModules! - if @moduleName - @tests\import @ref, modules, ... - else @tests\import modules, ... - - @tests\registerMacros! - @testsLoaded = true - - register: (selfRef, ...) => - -- replace dummy refs with real refs to own module - @ref.__index, @ref, LOADED_MODULES[@moduleName] = selfRef, selfRef, selfRef - @registerTests selfRef, ... - return selfRef - - registerMacro: (name=@name, description=@description, process, validate, isActive, submenu) => - -- alternative signature takes name and description from script - if type(name)=="function" - process, validate, isActive, submenu = name, description, process, validate - name, description = @name, @description - - -- use automation script name for submenu by default - submenu = @name if submenu == true - - menuName = { @config.c.customMenu } - menuName[#menuName+1] = submenu if submenu - menuName[#menuName+1] = name - - -- check for updates before running a macro - processHooked = (sub, sel, act) -> - @@updater\scheduleUpdate @ - @@updater\releaseLock! - return process sub, sel, act - - aegisub.register_macro table.concat(menuName, "/"), description, processHooked, validate, isActive - - registerMacros: (macros = {}, submenuDefault = true) => - for macro in *macros - -- allow macro table to omit name and description - submenuIdx = type(macro[1])=="function" and 4 or 6 - macro[submenuIdx] = submenuDefault if macro[submenuIdx] == nil - @registerMacro unpack(macro, 1, 6) - - setVersion: (version) => - version, err = @@parseVersion version - if version - @version = version - return version - else return nil, err - - validateNamespace: (namespace = @namespace, isVirtual = @virtual) => - return isVirtual or namespaceValidation\match @namespace - - uninstall: (removeConfig = true) => - if @virtual or @recordType == @@RecordType.Unmanaged - return nil, msgs.uninstall.noVirtualOrUnmanaged\format @virtual and "virtual" or "unmanaged", - @@terms.scriptType.singular[@scriptType], - @name - @config\delete! - subModules, mdlConfig = @getSubmodules! - -- uninstalling a module also removes all submodules - if subModules and #subModules > 0 - mdlConfig.c[mdl] = nil for mdl in *subModules - mdlConfig\write! - - toRemove, pattern, dir = {} - if @moduleName - nsp, name = @namespace\match "(.+)%.(.+)" - pattern = "^#{name}" - dir = "#{@automationDir}/#{nsp\gsub '%.', '/'}" - else - pattern = "^#{@namespace}"\gsub "%.", "%%." - dir = @automationDir - - lfs.chdir dir - for file in lfs.dir dir - mode, path = FileOps.attributes file, "mode" - -- parent level module files must be .ext - currPattern = @moduleName and mode == "file" and pattern.."%." or pattern - -- automation scripts don't use any subdirectories - if (@moduleName or mode == "file") and file\match currPattern - toRemove[#toRemove+1] = path - return FileOps.remove toRemove, true, true \ No newline at end of file +json = require "json" +lfs = require "lfs" + +constants = require "l0.DependencyControl.Constants" +Common = require "l0.DependencyControl.Common" +Logger = require "l0.DependencyControl.Logger" +ConfigView = require "l0.DependencyControl.ConfigView" +FileOps = require "l0.DependencyControl.FileOps" +Updater = require "l0.DependencyControl.Updater" +ModuleLoader = require "l0.DependencyControl.ModuleLoader" +ModuleProvider = require "l0.DependencyControl.ModuleProvider" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" +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 = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Records" +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 + msgs = { + new: { + badRecordError: "Error: Bad #{constants.DEPCTRL_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 #{constants.DEPCTRL_NAME} can be uninstalled)." + } + writeConfig: { + 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/#{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:{}, + tryAllFeeds:false, dumpFeeds:true, configDir:"?user/config", + logMaxFiles: 200, logMaxAge: 604800, logMaxSize:10*(10^6), + updateWaitTimeout: 60, updateOrphanTimeout: 600, + 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! + @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 } + + @updater = Updater script_namespace, @config, @logger + @configDir = @config.c.configDir + + FileOps.mkdir aegisub.decode_path @configDir + logsHaveBeenTrimmed or= @logger\trimFiles! + FileOps.runScheduledRemoval @configDir + + + --- Creates a DependencyControl record from explicit arguments and/or script globals. + -- @param args table + new: (args) => + init Record unless @@logger + + -- defaults + args[k] = v for k, v in pairs { + readGlobalScriptVars: true + saveRecordToConfig: true + } when args[k] == nil + + {@requiredModules, moduleName:@moduleName, configFile:configFile, virtual:@virtual, :name, + description:@description, url:@url, feed:@feed, recordType:@recordType, :namespace, + author:@author, :version, configFile:@configFile, :provides, + :readGlobalScriptVars, :saveRecordToConfig} = args + + @recordType or= @@RecordType.Managed + -- also support name key (as used in configuration) for required modules + @requiredModules or= args.requiredModules + + if @moduleName + @namespace = @moduleName + @name = name or @moduleName + @scriptType = @@ScriptType.Module + ModuleLoader.createDummyRef @ unless @virtual or @recordType == @@RecordType.Unmanaged + + else + if @virtual or not readGlobalScriptVars + @name = name or namespace + @namespace = namespace + version or= 0 + else + @name = name or script_name + @description or= script_description + @author or= script_author + version or= script_version + + @namespace = namespace or script_namespace + assert @recordType == @@RecordType.Managed, msgs.new.badRecordError\format msgs.new.badRecord.noUnmanagedMacros + assert @namespace, msgs.new.badRecordError\format msgs.new.badRecord.missingNamespace + @scriptType = @@ScriptType.Automation + + -- if the hosting macro doesn't have a namespace defined, define it for + -- the first DepCtrled module loaded by the macro or its required modules + unless script_namespace + export script_namespace = @namespace + + -- non-depctrl record don't need to conform to namespace rules + assert @virtual or @recordType == @@RecordType.Unmanaged or @validateNamespace!, + msgs.new.badRecord.badNamespace\format @namespace + + @configFile = configFile or "#{@namespace}.json" + @automationDir = Common\getAutomationDir @scriptType + @testDir = Common\getTestDir @scriptType + @version, err = SemanticVersioning\toNumber version + assert @version, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err + + @requiredModules or= {} + -- normalize short format module tables + for i, mdl in pairs @requiredModules + switch type mdl + when "table" + mdl.moduleName or= mdl[1] + mdl[1] = nil + when "string" + @requiredModules[i] = {moduleName: mdl} + else error msgs.new.badRecordError\format msgs.new.badRecord.badModuleTable\format i, tostring mdl + + -- normalize `provides` aliases (bare string -> {name: …}) and register them so + -- `require`-ing a provided alias resolves to this module (see ModuleProvider) + if @provides + @provides = [type(alias) == "table" and alias or {name: alias} for alias in *@provides] + ModuleProvider\registerRecord @ + + -- 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 + + --- Loads global DependencyControl configuration. + -- @return ConfigView + @loadConfig = => + if @config + @config\load! + else @config = ConfigView\get @depConf.file, {"config"}, @depConf.globalDefaults, @logger + + --- Loads this record's script/module configuration hive. + -- @param[opt=false] importRecord boolean + -- @return boolean + loadConfig: (importRecord = false) => + -- virtual modules are not yet present on the user's system and have no persistent configuration + @config or= ConfigView\get not @virtual and @@depConf.file, + { @@ScriptType.name.legacy[@scriptType], @namespace }, {}, @@logger, true + + -- import and overwrites version record from the configuration + if importRecord + -- check if a module that was previously virtual was installed in the meantime + -- TODO: prevent issues caused by orphaned config entries + haveConfig = false + if @virtual + @config\setFile @@depConf.file + if @config\load! + haveConfig, @virtual = true, false + else @config\unsetFile! + else + haveConfig = @config\load! + + -- only need to refresh data if the record was changed by an update + if haveConfig + @[key] = @config.c[key] for key in *@@depConf.scriptFields + + elseif not @virtual + -- copy script information to the config + @config\load! + shouldWriteConfig = @config\import @, @@depConf.scriptFields, false, true + return shouldWriteConfig + + return false + + --- Writes this record's persisted fields to the shared config file. + -- @return nil + writeConfig: => + unless @virtual or @config.file + @config\setFile @@depConf.file + + @@logger\trace msgs.writeConfig.writing, @@terms.scriptType.singular[@scriptType] + @config\import @, @@depConf.scriptFields, false, true + success, errMsg = @config\save! + + assert success, msgs.writeConfig.error\format errMsg + + + -- retained for compatibility with DepCtrl <= v0.6.3 + -- TODO: deprecate w/ v0.7.0 and remove in next major release + @getVersionNumber = SemanticVersioning.toNumber + @getVersionString = SemanticVersioning.toString + + + --- Resolves this record's external config file path. + -- @return string + getConfigFileName: () => + return aegisub.decode_path "#{@@configDir}/#{@configFile}" + + --- Creates a ConfigView for this record's script-specific config file. + -- @param[opt] defaults table + -- @param[opt] section string|string[] + -- @param[opt] noLoad boolean + -- @return ConfigView + getConfigHandler: (defaults, section, noLoad) => + return ConfigView\get @getConfigFileName!, section, defaults, nil, noLoad + + --- Creates a logger preconfigured for this record. + -- @param[opt] args table + -- @return Logger + getLogger: (args = {}) => + args.fileBaseName or= @namespace + args.toFile = @config.c.logToFile if args.toFile == nil + args.defaultLevel or= @config.c.logLevel + args.prefix or= @moduleName and "[#{@name}]" + + return Logger args + + --- Checks whether this record's version satisfies a minimum version. + -- @param value number|string|Record + -- @param[opt="patch"] precision SemverPrecision + -- @return boolean|nil + -- @return number|string|nil + checkVersion: (value, precision = "patch") => + if type(value) == "table" and value.__class == @@ + value = value.version + return SemanticVersioning\check @version, value + + + --- Retrieves managed submodules registered under this module namespace. + -- @return string[]|nil + -- @return ConfigView|nil + getSubmodules: => + return nil if @virtual or @recordType == @@RecordType.Unmanaged or @scriptType != @@ScriptType.Module + mdlConfig = @@config\getSectionHandler @@ScriptType.name.legacy[@@ScriptType.Module] + pattern = "^#{@namespace}."\gsub "%.", "%%." + return [mdl for mdl, _ in pairs mdlConfig.c when mdl\match pattern], mdlConfig + + --- Loads or updates required modules and returns their references. + -- @param[opt] modules table[] + -- @param[opt] addFeeds string[] + -- @return ... any + requireModules: (modules = @requiredModules, addFeeds = {@feed}) => + success, err = ModuleLoader.loadModules @, modules, addFeeds + @@updater\releaseLock! + unless success + -- if we failed loading our required modules + -- then that means we also failed to load + LOADED_MODULES[@namespace] = nil + @@logger\error err + return unpack [mdl._ref for mdl in *modules] + + --- Registers DepUnit tests for this record if test modules are available. + -- @param[opt] ... any + registerTests: (...) => + 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, ... + + 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 + -- @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) => + @registerTests! + -- alternative signature takes name and description from script + if type(name)=="function" + process, validate, isActive, submenu = name, description, process, validate + name, description = @name, @description + + -- use automation script name for submenu by default + submenu = @name if submenu == true + + menuName = { @config.c.customMenu } + menuName[#menuName+1] = submenu if submenu + menuName[#menuName+1] = name + + -- check for updates before running a macro + processHooked = (sub, sel, act) -> + @@updater\scheduleUpdate @ + @@updater\releaseLock! + return process sub, sel, act + + aegisub.register_macro table.concat(menuName, "/"), description, processHooked, validate, isActive + + --- Registers multiple macros declared in table form. + -- @param[opt] macros table[] + -- @param[opt=true] submenuDefault boolean + registerMacros: (macros = {}, submenuDefault = true) => + @registerTests! + for macro in *macros + -- allow macro table to omit name and description + submenuIdx = type(macro[1])=="function" and 4 or 6 + macro[submenuIdx] = submenuDefault if macro[submenuIdx] == nil + @registerMacro unpack(macro, 1, 6) + + --- Parses and sets this record's semantic version. + -- @param version number|string + -- @return number|nil + -- @return string|nil err + setVersion: (version) => + version, err = SemanticVersioning\toNumber version + if version + @version = version + return version + else return nil, err + + --- Validates this record's namespace, always passing for virtual records. + -- @return boolean + validateNamespace: => + return true if @virtual + return Common.validateNamespace @namespace + + --- Uninstalls this managed record and removes matching files from automation paths. + -- @param[opt=true] removeConfig boolean + -- @return boolean|nil + -- @return table|string|nil + uninstall: (removeConfig = true) => + if @virtual or @recordType == @@RecordType.Unmanaged + return nil, msgs.uninstall.noVirtualOrUnmanaged\format @virtual and "virtual" or "unmanaged", + @@terms.scriptType.singular[@scriptType], + @name + @config\delete! + subModules, mdlConfig = @getSubmodules! + -- uninstalling a module also removes all submodules + if subModules and #subModules > 0 + mdlConfig.c[mdl] = nil for mdl in *subModules + mdlConfig\write! + + toRemove, pattern, dir = {} + if @moduleName + nsp, name = @namespace\match "(.+)%.(.+)" + pattern = "^#{name}" + dir = "#{@automationDir}/#{nsp\gsub '%.', '/'}" + else + pattern = "^#{@namespace}"\gsub "%.", "%%." + dir = @automationDir + + lfs.chdir dir + for file in lfs.dir dir + mode, path = FileOps.attributes file, "mode" + -- parent level module files must be .ext + currPattern = @moduleName and mode == "file" and pattern.."%." or pattern + -- automation scripts don't use any subdirectories + if (@moduleName or mode == "file") and file\match currPattern + toRemove[#toRemove+1] = path + + -- 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/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/ScriptUpdateRecord.moon b/modules/l0/DependencyControl/ScriptUpdateRecord.moon new file mode 100644 index 0000000..419ff76 --- /dev/null +++ b/modules/l0/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 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 +---@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/l0/DependencyControl/SemanticVersioning.moon b/modules/l0/DependencyControl/SemanticVersioning.moon new file mode 100644 index 0000000..a7bcfc1 --- /dev/null +++ b/modules/l0/DependencyControl/SemanticVersioning.moon @@ -0,0 +1,96 @@ +SemanticVersioning = nil + +--- Semantic versioning utilities. +-- @class SemanticVersioning +class SemanticVersioning + msgs = { + toNumber: { + badString: "Can't parse version string '%s'. Make sure it conforms to semantic versioning standards." + badType: "Argument had the wrong type: expected a string or number, got a %s." + overflow: "Error: %s version must be an integer <= 255, got %s." + } + } + + semParts = {{"major", 16}, {"minor", 8}, {"patch", 0}} + + --- Converts a version number or string to a semantic version string. + ---@param version number|string + ---@param precision? SemverPrecision + ---@return string|nil versionString + ---@return string|nil err + @toString = (version, precision = "patch") => + if type(version) == "string" + version, err = @toNumber version + return nil, err unless version + + parts = {0, 0, 0} + for i, part in ipairs semParts + parts[i] = bit.rshift(version, part[2]) % 256 + break if precision == part[1] + + return "%d.%d.%d"\format unpack parts + + + --- Converts a semantic version string or number to an integer. + -- @param value string|number|nil The version as string (e.g. "1.2.3"), number, or nil. + -- @return number|false The integer version, or false on error. + -- @return string|nil Error message if conversion failed. + @toNumber = (value) => + return switch type value + when "number" then math.max value, 0 + when "nil" then 0 + when "string" + matches = {value\match "^(%d+)%.(%d+)%.(%d+)$"} + if #matches != 3 + return false, msgs.toNumber.badString\format value + + version = 0 + for i, part in ipairs semParts + value = tonumber matches[i] + if type(value) != "number" or value > 255 + return false, msgs.toNumber.overflow\format part[1], tostring value + + version += bit.lshift value, part[2] + version + + else false, msgs.toNumber.badType\format type value + + + --- Checks if version a is greater than or equal to version b, up to the given precision. + -- @param a number|string The first version (number or string). + -- @param b number|string The second version (number or string). + -- @param[opt="patch"] precision string The precision to use ("major", "minor", or "patch"). + -- @return boolean|nil True if a >= b, or nil on error. + -- @return number|nil The masked version of b, or error message if failed. + @check: (a, b, precision = "patch") => + if type(a) != "number" + a, err = @toNumber a + return nil, err unless a + + if type(b) != "number" + b, err = @toNumber b + return nil, err unless b + + mask = 0 + for part in *semParts + mask += 0xFF * 2^part[2] + break if precision == part[1] + + b = bit.band b, mask + return a >= b, b + + 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/Stub.moon b/modules/l0/DependencyControl/Stub.moon new file mode 100644 index 0000000..697a3dc --- /dev/null +++ b/modules/l0/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/l0/DependencyControl/TerribleMutex.moon b/modules/l0/DependencyControl/TerribleMutex.moon new file mode 100644 index 0000000..e6bbe82 --- /dev/null +++ b/modules/l0/DependencyControl/TerribleMutex.moon @@ -0,0 +1,77 @@ +-- 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. +-- +-- The mutex name embeds the process ID so concurrent Aegisub / test-launcher +-- instances never share the same lock. + +ffi = require "ffi" + +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 = 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); + 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 + 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 + 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 + -- mirrors the BM.BadMutex version this stands in for + version: "0.1.3" +} + +return mutex diff --git a/modules/l0/DependencyControl/Timer.moon b/modules/l0/DependencyControl/Timer.moon new file mode 100644 index 0000000..7bdbbfc --- /dev/null +++ b/modules/l0/DependencyControl/Timer.moon @@ -0,0 +1,68 @@ +-- Monotonic timer with millisecond sleep. +-- DepCtrl always uses this FFI-based implementation for consistent behavior. +-- If PT.PreciseTimer has not been loaded by the time this module runs, it is +-- registered under that name so other scripts requiring it get a working timer. + +ffi = require "ffi" + +local getTime, sleep + +if ffi.os == "Windows" + -- Separate pcalls: a Sleep redeclaration conflict must not block QPC/QPF. + pcall ffi.cdef, "int QueryPerformanceCounter(long long *lpPerformanceCount);" + pcall ffi.cdef, "int QueryPerformanceFrequency(long long *lpFrequency);" + pcall ffi.cdef, "unsigned int Sleep(unsigned int dwMilliseconds);" + + freq = ffi.new "long long[1]" + ffi.C.QueryPerformanceFrequency freq + freq = tonumber freq[0] + + counter = ffi.new "long long[1]" + getTime = -> + ffi.C.QueryPerformanceCounter counter + tonumber(counter[0]) / freq + + sleep = (ms) -> ffi.C.Sleep ms + +else + -- CLOCK_MONOTONIC: 1 on Linux, 6 on macOS + CLOCK_MONOTONIC = ffi.os == "OSX" and 6 or 1 + + pcall ffi.cdef, [[ + struct timespec { long tv_sec; long tv_nsec; }; + int clock_gettime(int clk_id, struct timespec *tp); + int poll(struct pollfd *fds, unsigned long nfds, int timeout); + ]] + + ts = ffi.new "struct timespec" + getTime = -> + ffi.C.clock_gettime CLOCK_MONOTONIC, ts + tonumber(ts.tv_sec) + tonumber(ts.tv_nsec) * 1e-9 + + sleep = (ms) -> ffi.C.poll nil, 0, ms + + +class Timer + --- Creates a new timer, capturing the current time as the start point. + new: => + @startTime = getTime! + + --- Returns wall-clock seconds elapsed since construction. + ---@return number seconds + timeElapsed: => + getTime! - @startTime + + --- Sleeps for the given number of milliseconds. + ---@param ms number + sleep: sleep + + @sleep = sleep + + +-- Try loading the real PT.PreciseTimer so other scripts can use it if available. +-- If it's unavailable (no native build, missing dependencies), inject our +-- Timer as a fallback so those scripts still get a working implementation. +if not pcall require, "PT.PreciseTimer" + package.loaded["PT.PreciseTimer"] = Timer + +return Timer diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/l0/DependencyControl/UnitTestSuite.moon similarity index 72% rename from modules/DependencyControl/UnitTestSuite.moon rename to modules/l0/DependencyControl/UnitTestSuite.moon index 64b9301..7302b03 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/l0/DependencyControl/UnitTestSuite.moon @@ -1,6 +1,9 @@ Logger = require "l0.DependencyControl.Logger" -re = require "aegisub.re" +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;" @@ -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,14 @@ 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 return @success, @errMsg @@ -140,59 +150,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 +167,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 +246,12 @@ class UnitTest -- @tparam {[string]={value, string}} args a hashtable of argument values and expected types -- indexed by the respective argument names checkArgTypes: (args) => - i, expected, actual = 1 + i = 1 for name, types in pairs args - actual, expected = types[2], type types[1] - continue if expected == "_any" - @logger\assert actual == expected, @@msgs.assert.checkArgTypes, i, name, - expected, @format "type", types[1] + declared, actual = types[2], type types[1] + continue if declared == "_any" + @logger\assert declared == actual, @@msgs.assert.checkArgTypes, i, name, + declared, @format "type", types[1] i += 1 @@ -554,19 +478,14 @@ class UnitTest -- string asserts --- Fails the assertion if a string doesn't match the specified pattern. - -- Supports both Lua and Regex patterns. + -- Accepts a Lua string pattern or a compiled aegisub.re pattern object. -- @tparam string str the input string - -- @tparam string pattern the pattern to be matched against - -- @tparam[opt=false] boolean useRegex Enable this option to use Regex instead of Lua patterns - -- @tparam[optchain] re.Flags flags Any amount of regex flags as defined by the Aegisub re module - -- (see here for details: http://docs.aegisub.org/latest/Automation/Lua/Modules/re/#flags) - assertMatches: (str, pattern, useRegex = false, ...) => - @checkArgTypes { str: {str, "string"}, pattern: {pattern, "string"}, - useRegex: {useRegex, "boolean"} - } - - match = useRegex and re.match(str, pattern, ...) or str\match pattern, ... - @assert match, @@msgs.assert.matches, str, useRegex and "regex" or "Lua", pattern + -- @param pattern string|userdata Lua pattern string or compiled aegisub.re pattern + assertMatches: (str, pattern) => + @checkArgTypes { str: {str, "string"} } + isLuaPattern = type(pattern) == "string" + match = isLuaPattern and str\match(pattern) or pattern\match str + @assert match, @@msgs.assert.matches, str, (isLuaPattern and "Lua" or "regex"), tostring pattern --- Fails the assertion if a string doesn't contain a specified substring. -- Search is case-sensitive by default. @@ -601,21 +520,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 @@ -629,9 +543,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] @@ -660,8 +577,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. @@ -673,6 +595,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. @@ -680,7 +606,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] @@ -691,6 +619,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} @@ -703,24 +644,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 @@ -729,8 +679,22 @@ 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 class UnitTestSuite msgs = { run: { @@ -753,6 +717,38 @@ 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. @@ -760,7 +756,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. @@ -786,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 @@ -800,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 @@ -824,6 +831,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 @@ -834,10 +842,99 @@ class UnitTestSuite @logger\warn msgs.run.abort, i return false, allFailed + @endTime = os.time! * 1000 @logger.indent -= 1 @success = failedCnt == 0 if @success @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 + + --- Collects the results of the most recent run into a flat, format-agnostic structure. + -- 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?, skipped?, skipReason?}, ...}}, ...} + 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 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 + 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 + + --- 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, skipped = {}, 0, 0, 0 + for suite in *@collectResults! + for c in *suite.cases + entry = { + name: c.name + suite: c.classname + duration: math.floor c.duration * 1000 + 0.5 -- seconds -> ms + } + 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 + skipped + :passed, :failed, :skipped + pending: 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 "l0.DependencyControl.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/modules/l0/DependencyControl/UpdateFeed.moon b/modules/l0/DependencyControl/UpdateFeed.moon new file mode 100644 index 0000000..f0d7bbd --- /dev/null +++ b/modules/l0/DependencyControl/UpdateFeed.moon @@ -0,0 +1,729 @@ +-- 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" +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" +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 +-- 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 + +-- 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 + templateData = { + maxDepth: 7 + templates: { + feedName: {depth: 1, order: 1, key: "name" } + baseUrl: {depth: 1, order: 2, key: "baseUrl" } + feed: {depth: 1, order: 3, key: "knownFeeds", isHashTable: true } + namespace: {depth: 3, order: 1, parentKeys: {macros:true, modules:true} } + namespacePath: {depth: 3, order: 2, parentKeys: {macros:true, modules:true}, repl:"%.", to: "/" } + scriptName: {depth: 3, order: 3, key: "name" } + channel: {depth: 5, order: 1, parentKeys: {channels:true} } + version: {depth: 5, order: 2, key: "version" } + platform: {depth: 7, order: 1, key: "platform" } + fileName: {depth: 7, order: 2, key: "name" } + -- rolling templates + localFileBasePath: { + key: "localFileBasePath", + rolling: true, + expansionModes: {local: true}, + default: "./" + } + fileBaseUrl: { + key: "fileBaseUrl", + rolling: true + } + } + sourceAt: {} + } + + msgs = { + trace: { + usingCached: "Using cached feed." + 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)." + 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" + } + 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 = { + dumpExpanded: false + } + @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 = "#{constants.DEPCTRL_NAMESPACE}_" + fileMatchTemplate = "#{constants.DEPCTRL_NAMESPACE}_%x%x%x%x.*%.json" + feedsHaveBeenTrimmed = false + + -- precalculate some tables for the templater + templateData.rolling = {n, true for n,t in pairs templateData.templates when t.rolling} + templateData.sourceKeys = {t.key, t.depth for n,t in pairs templateData.templates when t.key} + with templateData + for i=1,.maxDepth + .sourceAt[i], j = {}, 1 + for name, tmpl in pairs .templates + if tmpl.depth==i and not tmpl.rolling + .sourceAt[i][j] = name + 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] autoLoad boolean + -- @param[opt] fileName string + -- @param[opt] config table + -- @param[opt] logger Logger + 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' + 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 + + @ensureLoaded! if autoLoad + + --- 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 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, expansionMode) => + -- Initialize download infrastructure lazily on first fetch. + unless @downloadManager + @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 + @fileName = fileName if fileName + + dl, err = @downloadManager\addDownload @url, @fileName + unless dl + return false, msgs.errors.downloadAdd\format @url, @fileName, err + + @downloadManager\waitForFinish -> true + if dl.error + return false, msgs.errors.downloadFailed\format @url, @fileName, dl.error + + @logger\trace msgs.trace.downloaded, @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 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 + + -- 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 + @feedPath = srcPath + @feedDir = srcPath\match("^(.*)[/\\][^/\\]*$") or "." + + @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 or (@_url and @@ExpansionMode.Remote or @@ExpansionMode.Local)) => + {:templates, :maxDepth, :sourceAt, :rolling, :sourceKeys} = templateData + isLocalMode = mode == @@ExpansionMode.Local + vars, rvars = {}, {i, {} for i=0, maxDepth} + + expandTemplates = (val, depth, rOff=0) -> + return switch type val + when "string" + val = val\gsub "@{(.-):(.-)}", (name, key) -> + if type(vars[name]) == "table" or type(rvars[depth+rOff]) == "table" + 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} + else val + + + recurse = (obj, depth = 1, parentKey = "", upKey = "") -> + -- collect regular template variables first + for name in *sourceAt[depth] + with templates[name] + if not .key + -- template variables are not expanded if they are keys + vars[name] = parentKey if .parentKeys[upKey] + elseif .key and obj[.key] + -- expand other templates used in template variable + obj[.key] = expandTemplates obj[.key], depth + vars[name] = obj[.key] + vars[name] = vars[name]\gsub(.repl, .to) if .repl + + -- update rolling template variables last + for name,_ in pairs rolling + 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] = 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] + switch type v + when "string" + obj[k] = expandTemplates obj[k], depth + when "table" + recurse v, depth+1, k, parentKey + -- invalidate template variables created at depth+1 + vars[name] = nil for name in *sourceAt[depth+1] + rvars[depth+1] = {} + + recurse @data + @expansionMode = mode + + if @dumpExpanded + handle = io.open @fileName\gsub(".json$", ".exp.json"), "w" + handle\write(dkjson.encode @data, indentMode: "prettier")\close! + + return @data + + --- Retrieves a script update record by namespace and type. + -- @param namespace string + -- @param scriptType number|boolean + -- @param[opt] config table + -- @param[opt] autoChannel boolean + -- @return ScriptUpdateRecord|boolean|nil + -- @return string|nil err + getScript: (namespace, scriptType, config, autoChannel) => + -- legacy compatibility for <= 0.6.3 + if scriptType == true then scriptType = @@ScriptType.Module + elseif scriptType == false then scriptType = @@ScriptType.Automation + + section = @@ScriptType.name.legacy[scriptType] + unless section + 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 + 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 + + --- 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 + + --- 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 "l0.DependencyControl" + 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 + -- @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!) => + @ensureLoaded! + 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!) => + @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 {} + coroutine.yield file, chanProxy, pkg, section, scriptType diff --git a/modules/DependencyControl/Updater.moon b/modules/l0/DependencyControl/Updater.moon similarity index 80% rename from modules/DependencyControl/Updater.moon rename to modules/l0/DependencyControl/Updater.moon index 888a105..38ff2dc 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/l0/DependencyControl/Updater.moon @@ -1,14 +1,16 @@ 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" Common = require "l0.DependencyControl.Common" ModuleLoader = require "l0.DependencyControl.ModuleLoader" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" DependencyControl = nil +--- Shared updater error decoding and base behavior. +-- @class UpdaterBase class UpdaterBase extends Common @logger = Logger fileBaseName: "DependencyControl.Updater" msgs = { @@ -21,20 +23,29 @@ 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" + [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." - [56]: "%s of %s '%s' succeeded, but an error occured while loading the module:\n%s" + [56]: "%s of %s '%s' succeeded, but an error occurred while loading the module:\n%s" [57]: "%s of %s '%s' succeeded, but it's missing a version record." - [58]: "%s of unmanaged %s '%s' succeeded, but an error occured while creating a DependencyControl record: %s" + [58]: "%s of unmanaged %s '%s' succeeded, but an error occurred while creating a DependencyControl record: %s", [100]: "Error (%d) in component %s during %s of %s '%s':\n— %s" } updaterErrorComponent: {"DownloadManager (adding download)", "DownloadManager"} } + --- Converts updater status/error codes into user-facing error messages. + -- @param code number + -- @param name string + -- @param scriptType number + -- @param isInstall boolean + -- @param[opt] detailMsg string + -- @return string getUpdaterErrorMsg: (code, name, scriptType, isInstall, detailMsg) => if code <= -100 -- Generic downstream error @@ -46,6 +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 = { @@ -85,19 +98,28 @@ class UpdateTask extends UpdaterBase unknownType: "Skipping file '%s': unknown type '%s'." } refreshRecord: { - unsetVirtual: "Update initated by another macro already fetched %s '%s', switching to update mode." - otherUpdate: "Update initated by another macro already updated %s '%s' to v%s." + unsetVirtual: "Update initiated by another macro already fetched %s '%s', switching to update mode." + otherUpdate: "Update initiated by another macro already updated %s '%s' to v%s." } } - new: (@record, targetVersion = 0, @addFeeds, @exhaustive, @channel, @optional, @updater) => + --- Creates an update task for one record. + -- @param record Record + -- @param[opt=0] targetVersionNumber number + -- @param[opt] addFeeds string[] + -- @param[opt] exhaustive boolean + -- @param[opt] channel string + -- @param[opt] optional boolean + -- @param updater Updater + new: (@record, targetVersionNumber = 0, @addFeeds, @exhaustive, @channel, @optional, @updater) => DependencyControl or= require "l0.DependencyControl" assert @record.__class == DependencyControl, "First parameter must be a #{DependencyControl.__name} object." + assert type(targetVersionNumber) == "number", "Second parameter must be a semantic version number in integer format." @logger = @updater.logger @triedFeeds = {} @status = nil - @targetVersion = DependencyControl\parseVersion targetVersion + @targetVersion = targetVersionNumber -- set UpdateFeed settings @feedConfig = { @@ -108,10 +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! - set: (targetVersion, @addFeeds, @exhaustive, @channel, @optional) => - @targetVersion = DependencyControl\parseVersion targetVersion - return @ - + --- Loads and validates one feed candidate for the current update task. + -- @param feedUrl string + -- @return table|boolean|nil + -- @return string|number|nil + -- @return number|nil checkFeed: (feedUrl) => -- get feed contents feed = UpdateFeed feedUrl, false, nil, @feedConfig, @logger @@ -121,8 +144,9 @@ class UpdateTask extends UpdaterBase return nil, msgs.checkFeed.downloadFailed\format err -- select our script and update channel - updateRecord = feed\getScript @record.namespace, @record.scriptType, @record.config, false + updateRecord, err = feed\getScript @record.namespace, @record.scriptType, @record.config, false unless updateRecord + return nil, err if err return nil, msgs.checkFeed.noData\format @@terms.scriptType.singular[@record.scriptType], @record.name success, currentChannel = updateRecord\setChannel @channel @@ -147,8 +171,13 @@ class UpdateTask extends UpdaterBase return true, updateRecord, version + --- Runs the full update/install flow for this task. + -- @param[opt] waitLock boolean + -- @param[opt] exhaustive boolean + -- @return number statusCode + -- @return any detail run: (waitLock, exhaustive = @updater.config.c.tryAllFeeds or @@exhaustive) => - logUpdateError = (code, extErr, virtual = @virtual) -> + logUpdateError = (code, extErr, virtual = @record.virtual) -> if code < 0 @logger\log @getUpdaterErrorMsg code, @record.name, @record.scriptType, virtual, extErr return code, extErr @@ -161,7 +190,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 +258,12 @@ class UpdateTask extends UpdaterBase -- and the version must at least be that returned by at least one feed if maxVer>0 and not @record.virtual and @targetVersion <= @record.version @logger\log msgs.run.upToDate, @@terms.scriptType.singular[@record.scriptType], - @record.name, DependencyControl\getVersionString @record.version + @record.name, SemanticVersioning\toString @record.version return 0 - res = msgs.run.noFeedAvailExt\format @targetVersion == 0 and "any" or DependencyControl\getVersionString(@targetVersion), - @record.virtual and "no" or DependencyControl\getVersionString(@record.version), - maxVer<1 and "none" or DependencyControl\getVersionString maxVer + res = msgs.run.noFeedAvailExt\format @targetVersion == 0 and "any" or SemanticVersioning\toString(@targetVersion), + @record.virtual and "no" or SemanticVersioning\toString(@record.version), + maxVer<1 and "none" or SemanticVersioning\toString maxVer if @optional @logger\log msgs.run.skippedOptional, @record.name, @@terms.isInstall[@record.virtual], @@ -246,6 +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 @@ -285,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 @@ -301,11 +335,11 @@ 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" + 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 @logger\log msgs.performUpdate.unknownType, file.name, file.type @@ -379,20 +413,22 @@ class UpdateTask extends UpdaterBase @ref = ref else with @record - .name, .version, .virtual = @record.name, DependencyControl\parseVersion update.version + .name = @record.name + .virtual = false + .version = SemanticVersioning\toNumber update.version @record\writeConfig! @updated = true - @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual]), + @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual or false]), @@terms.scriptType.singular[@record.scriptType], - @record.name, DependencyControl\getVersionString @record.version + @record.name, SemanticVersioning\toString @record.version - -- Diplay changelog - @logger\log update\getChangelog @record, (DependencyControl\parseVersion oldVer) + 1 + -- Display changelog + @logger\log update\getChangelog @record, (SemanticVersioning\toNumber oldVer) + 1 @logger\log msgs.performUpdate.reloadNotice -- TODO: check handling of private module copies (need extra return value?) - return finish 1, DependencyControl\getVersionString @record.version + return finish 1, SemanticVersioning\toString @record.version refreshRecord: => @@ -406,8 +442,10 @@ class UpdateTask extends UpdaterBase @logger\log msgs.refreshRecord.unsetVirtual, @@terms.scriptType.singular[.scriptType], .name else @logger\log msgs.refreshRecord.otherUpdate, @@terms.scriptType.singular[.scriptType], .name, - DependencyControl\getVersionString @record.version + SemanticVersioning\toString @record.version +--- Coordinates background update checks and update task lifecycle. +-- @class Updater class Updater extends UpdaterBase msgs = { getLock: { @@ -425,9 +463,23 @@ class Updater extends UpdaterBase runningUpdate: "Running scheduled update for %s '%s'..." } } + --- Creates an updater coordinator for one host script context. + -- @param[opt] host string + -- @param config ConfigHandler + -- @param[opt] logger Logger new: (@host = script_namespace, @config, @logger = @@logger) => @tasks = {scriptType, {} for _, scriptType in pairs @@ScriptType when "number" == type scriptType} + --- Creates or updates a queued update task for a record. + -- @param record Record|table + -- @param[opt] targetVersion number|string + -- @param[opt] addFeeds string[] + -- @param[opt] exhaustive boolean + -- @param[opt] channel string + -- @param[opt] optional boolean + -- @return UpdateTask|nil + -- @return number|nil code + -- @return string|nil detail addTask: (record, targetVersion, addFeeds = {}, exhaustive, channel, optional) => DependencyControl or= require "l0.DependencyControl" if record.__class != DependencyControl @@ -435,18 +487,28 @@ class Updater extends UpdaterBase depRec[k] = v for k, v in pairs record record = DependencyControl depRec - task = @tasks[record.scriptType][record.namespace] - if task - return task\set targetVersion, addFeeds, exhaustive, channel, optional - else - task, err = UpdateTask record, targetVersion, addFeeds, exhaustive, channel, optional, @ - @tasks[record.scriptType][record.namespace] = task - return task, err + targetVersionNumber, err = SemanticVersioning\toNumber targetVersion + if (err) then return nil, -8, err + task = @tasks[record.scriptType][record.namespace] + return if task then with task + .targetVersion = targetVersionNumber + .addFeeds, .exhaustive, .channel, .optional = addFeeds, exhaustive, channel, optional + + task, code = UpdateTask record, targetVersionNumber, addFeeds, exhaustive, channel, optional, @ + @tasks[record.scriptType][record.namespace] = task + return task, code + + --- Ensures a module dependency is installed/updated and loadable. + -- @param record Record + -- @param[opt] ... any + -- @return any + -- @return number|nil code + -- @return string|nil detail require: (record, ...) => @logger\assert record.scriptType == @@ScriptType.Module, msgs.require, record.name or record.namespace @logger\log "%s module '%s'...", record.virtual and "Installing required" or "Updating outdated", record.name - task, code = @addTask record, ... + task, code, res = @addTask record, ... code, res = task\run true if task if code == 0 and not task.updated @@ -459,6 +521,9 @@ class Updater extends UpdaterBase else -- pass on update errors return nil, code, res + --- Performs a periodic non-blocking update check for a managed record. + -- @param record Record + -- @return number|boolean scheduleUpdate: (record) => unless @config.c.updaterEnabled @logger\trace msgs.scheduleUpdate.updaterDisabled, record.name or record.namespace @@ -480,6 +545,11 @@ class Updater extends UpdaterBase return task\run! + --- Acquires the global updater lock shared across scripts. + -- @param doWait boolean + -- @param[opt] waitTimeout number + -- @return boolean + -- @return string|nil lockOwner getLock: (doWait, waitTimeout = @config.c.updateWaitTimeout) => return true if @hasLock @@ -493,7 +563,7 @@ class Updater extends UpdaterBase @logger\log msgs.getLock.waiting, running.host timeout, didWait = waitTimeout, true while running and timeout > 0 - PreciseTimer.sleep 1000 + Timer.sleep 1000 timeout -= 1 @config\load! running = @config.c.updaterRunning @@ -517,8 +587,10 @@ class Updater extends UpdaterBase return true + --- Releases the global updater lock. + -- @return boolean releaseLock: => return false unless @hasLock @hasLock = false @config.c.updaterRunning = false - @config\write! \ No newline at end of file + @config\write! diff --git a/modules/l0/DependencyControl/ZipArchiver.moon b/modules/l0/DependencyControl/ZipArchiver.moon new file mode 100644 index 0000000..b635845 --- /dev/null +++ b/modules/l0/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/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon new file mode 100644 index 0000000..8a36487 --- /dev/null +++ b/modules/l0/DependencyControl/test.moon @@ -0,0 +1,2292 @@ +constants = require "l0.DependencyControl.Constants" +DependencyControl = require "l0.DependencyControl" + +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 "#", ... + controls = select nArgs, ... + 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" + 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" + 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 "/" + basePath = aegisub.decode_path "?temp/l0.#{DependencyControl.__name}.#{DependencyControl.UnitTestSuite.__name}_#{'%04X'\format math.random 0, 16^4-1}" + + -- Fake transfer driver for Downloader.multiplex: each download completes after + -- `steps` step() calls (1 byte each), recording the order step() is called so + -- tests can assert round-robin fairness without any real network I/O. + makeFakeDriver = (steps, order) -> + { + start: (dl) -> + dl.totalBytes = steps + dl.bytesReceived = 0 + true + step: (dl) -> + order[#order + 1] = dl.id + dl.bytesReceived += 1 + return "done" if dl.bytesReceived >= steps + "more" + finish: (dl) -> nil + } + + -- builds a downloader whose runner drives multiplex with the given fake driver + fakeManager = (driver) -> + Downloader (mgr) -> Downloader.multiplex mgr, driver + + Status = Downloader.Download.Status + + -- generates a process-unique module-alias name (the ModuleProvider registry is global) + uniqueName = (prefix) -> "#{prefix}_#{'%08X'\format math.random 0, 16^8-1}" + + -- 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." + + -- timeElapsed + + timeElapsed_nonNegative: (ut) -> + t = Timer! + ut\assertGreaterThanOrEquals t\timeElapsed!, 0 + + timeElapsed_monotonic: (ut) -> + t = Timer! + a = t\timeElapsed! + b = t\timeElapsed! + ut\assertGreaterThanOrEquals b, a + + timeElapsed_advancesAfterSleep: (ut) -> + t = Timer! + Timer.sleep 20 -- 20 ms + -- Require at least 10 ms to pass; allows 50% margin for CI jitter. + ut\assertGreaterThan t\timeElapsed!, 0.010 + + -- sleep + + sleep_isCallable: (ut) -> + -- Smoke test: sleep(0) must not error and must return. + Timer.sleep 0 + ut\assertTrue true + + sleep_onClass: (ut) -> + -- sleep is a static method accessible directly on the class. + ut\assertFunction Timer.sleep + + sleep_onInstance: (ut) -> + -- sleep is also accessible through an instance (class method inheritance). + t = Timer! + ut\assertFunction t.sleep + + _order: { + "timeElapsed_nonNegative", "timeElapsed_monotonic", + "timeElapsed_advancesAfterSleep", + "sleep_isCallable", "sleep_onClass", "sleep_onInstance" + } + } + + TerribleMutex: { + _description: "Tests for TerribleMutex: FFI-based process-scoped mutex that fills in for BM.BadMutex." + + -- API surface + + api_hasTryLock: (ut) -> + ut\assertFunction TerribleMutex.tryLock + + api_hasLock: (ut) -> + ut\assertFunction TerribleMutex.lock + + api_hasUnlock: (ut) -> + ut\assertFunction TerribleMutex.unlock + + -- tryLock / unlock round-trip + + tryLock_acquires: (ut) -> + result = TerribleMutex.tryLock! + ut\assertTrue result + TerribleMutex.unlock! -- release so subsequent tests start clean + + tryLock_failsWhenHeld: (ut) -> + ut\assertTrue TerribleMutex.tryLock! -- acquire + result = TerribleMutex.tryLock! -- second attempt must fail + TerribleMutex.unlock! + ut\assertFalse result + + unlock_releasesLock: (ut) -> + ut\assertTrue TerribleMutex.tryLock! + TerribleMutex.unlock! + result = TerribleMutex.tryLock! -- must succeed again after release + TerribleMutex.unlock! + ut\assertTrue result + + -- BM.BadMutex alias + + registered_asBadMutex: (ut) -> + -- 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: { + "api_hasTryLock", "api_hasLock", "api_hasUnlock", + "tryLock_acquires", "tryLock_failsWhenHeld", "unlock_releasesLock", + "registered_asBadMutex" + } + } + + Crypto: { + _description: "Tests for the pure-Lua Crypto utilities (SHA-1) against known vectors." + + sha1_abc: (ut) -> + ut\assertEquals Crypto.sha1("abc"), "a9993e364706816aba3e25717850c26c9cd0d89d" + + sha1_empty: (ut) -> + ut\assertEquals Crypto.sha1(""), "da39a3ee5e6b4b0d3255bfef95601890afd80709" + + -- exercises multi-block padding (>55 bytes) + sha1_quickBrownFox: (ut) -> + ut\assertEquals Crypto.sha1("The quick brown fox jumps over the lazy dog"), + "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12" + + -- binary payloads (embedded NUL and high bytes) hash without error + sha1_binaryData: (ut) -> + digest = Crypto.sha1 "\0\1\2\254\255" + ut\assertMatches digest, "^%x+$" + ut\assertEquals #digest, 40 + + sha1_rejectsNonString: (ut) -> + result, err = Crypto.sha1 42 + ut\assertNil result + ut\assertString err + + -- whichever backend is active (native or lua) must match the reference impl + sha1_backendMatchesReference: (ut) -> + for input in *{"", "abc", "The quick brown fox jumps over the lazy dog", "\0\1\2\254\255"} + ut\assertEquals Crypto.sha1(input), Crypto._sha1Lua(input) + + _order: { + "sha1_abc", "sha1_empty", "sha1_quickBrownFox", + "sha1_binaryData", "sha1_rejectsNonString", "sha1_backendMatchesReference" + } + } + + ModuleProvider: (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.)" + + -- round-robin scheduling: the scheduler must step every active transfer once + -- per pass, so two downloads interleave rather than running one to completion first + + roundRobin_interleaves: (ut) -> + order = {} + dm = fakeManager makeFakeDriver 3, order + dm\addDownload "http://x/1", "#{basePath}_rr1" + dm\addDownload "http://x/2", "#{basePath}_rr2" + dm\await! + -- 2 downloads × 3 steps; each pass touches both before re-stepping either + ut\assertEquals #order, 6 + ut\assertNotEquals order[1], order[2] -- first pass touched both + ut\assertNotEquals order[3], order[4] -- second pass too + ut\assertEquals dl.status, Status.Finished for dl in *dm.downloads + + -- the user-described scenario: start two slow downloads, detect (via the + -- progress callback) that both are in flight simultaneously, then abort early + + roundRobin_detectsConcurrencyThenCancels: (ut) -> + order = {} + dm = fakeManager makeFakeDriver 1000, order -- "slow": many steps to finish + dm\addDownload "http://x/1", "#{basePath}_c1" + dm\addDownload "http://x/2", "#{basePath}_c2" + + maxConcurrent = 0 + dm\on Downloader.Event.Progress, (downloader, percent) -> + inFlight = 0 + for dl in *dm.downloads + inFlight += 1 if dl.status == Status.Active and (dl.bytesReceived or 0) > 0 + maxConcurrent = math.max maxConcurrent, inFlight + dm\cancel! if maxConcurrent >= 2 -- proven concurrent → abort + dm\await! + + ut\assertGreaterThanOrEquals maxConcurrent, 2 + -- aborted after the first pass: neither 1000-step download finished + ut\assertEquals dl.status, Status.Cancelled for dl in *dm.downloads + + -- Finish event listeners fire on completion and may mark the download failed + -- (the mechanism SHA-1 verification rides on) + + finishEvent_canMarkFailed: (ut) -> + dm = fakeManager makeFakeDriver 1, {} + dl = dm\addDownload "http://x/1", "#{basePath}_fin" + fired = false + dl\on Downloader.Download.Event.Finish, (d) -> + fired = true + d\markFailed "verification failed" + dm\await! + ut\assertTrue fired + ut\assertEquals dl.error, "verification failed" + ut\assertEquals dl.status, Status.Failed + + -- on/off: a removed listener no longer fires + + on_off: (ut) -> + dl = Downloader.Download "http://x/1", "#{basePath}_o", 1 + count = 0 + cb = (d) -> count += 1 + dl\on Downloader.Download.Event.Progress, cb + dl\_notifyProgress! + dl\off Downloader.Download.Event.Progress, cb + dl\_notifyProgress! + ut\assertEquals count, 1 + + on_rejectsUnknownEvent: (ut) -> + dl = Downloader.Download "http://x/1", "#{basePath}_u", 1 + ut\assertError -> dl\on "notAnEvent", -> + + -- addDownload sha1: a matching hash leaves no error; a mismatch records one + + addDownload_sha1Verifies: (ut) -> + path = "#{basePath}_sha1ok.txt" + handle = io.open path, "wb" + handle\write "abc" + handle\close! + dm = fakeManager makeFakeDriver 1, {} + dl = dm\addDownload "http://x/1", path, "a9993e364706816aba3e25717850c26c9cd0d89d" + dm\await! + os.remove path + ut\assertNil dl.error + ut\assertEquals dl.status, Status.Finished + + addDownload_sha1Mismatch: (ut) -> + path = "#{basePath}_sha1bad.txt" + handle = io.open path, "wb" + handle\write "abc" + handle\close! + dm = fakeManager makeFakeDriver 1, {} + dl = dm\addDownload "http://x/1", path, ("0")\rep 40 + dm\await! + os.remove path + ut\assertString dl.error + ut\assertEquals dl.status, Status.Failed + + -- Downloader-level events: Progress fires during, Finished fires after await + + downloaderEvents: (ut) -> + dm = fakeManager makeFakeDriver 2, {} + dm\addDownload "http://x/1", "#{basePath}_de" + progressCount, finished = 0, false + dm\on Downloader.Event.Progress, (d, percent) -> progressCount += 1 + dm\on Downloader.Event.Finished, (d) -> finished = true + dm\await! + ut\assertGreaterThan progressCount, 0 + ut\assertTrue finished + + -- a failed start marks the download Failed with the start error + + runner_recordsStartFailure: (ut) -> + failingDriver = { + start: (dl) -> false, "boom" + step: (dl) -> "done" + finish: (dl) -> nil + } + dm = Downloader (mgr) -> Downloader.multiplex mgr, failingDriver + dm\addDownload "http://x/1", "#{basePath}_f1" + dm\await! + ut\assertEquals dm.downloads[1].error, "boom" + ut\assertEquals dm.downloads[1].status, Status.Failed + + -- a single download can be cancelled mid-flight without affecting the others + + individualCancel: (ut) -> + order = {} + dm = fakeManager makeFakeDriver 3, order + dl1 = dm\addDownload "http://x/1", "#{basePath}_ic1" + dl2 = dm\addDownload "http://x/2", "#{basePath}_ic2" + dm\on Downloader.Event.Progress, -> dl1\cancel! -- cancel dl1 once it's underway + dm\await! + ut\assertEquals dl1.status, Status.Cancelled + ut\assertEquals dl2.status, Status.Finished + + -- addDownload queueing and validation + + addDownload_queues: (ut) -> + dm = Downloader! + dl = dm\addDownload "https://example.com/x", "#{basePath}_dl.txt" + ut\assertEquals dl.url, "https://example.com/x" + ut\assertEquals #dm.downloads, 1 + + addDownload_badArgs: (ut) -> + dl, err = Downloader!\addDownload nil, nil + ut\assertNil dl + ut\assertString err + + -- clear empties the arrays in place (external references stay valid) + + clear_emptiesInPlace: (ut) -> + dm = Downloader! + downloadsRef = dm.downloads + dm\addDownload "http://x/1", "#{basePath}_cl" + dm\clear! + ut\assertEquals #dm.downloads, 0 + ut\assertIs dm.downloads, downloadsRef -- same table, emptied in place + + _order: { + "roundRobin_interleaves", "roundRobin_detectsConcurrencyThenCancels", + "finishEvent_canMarkFailed", "on_off", "on_rejectsUnknownEvent", + "addDownload_sha1Verifies", "addDownload_sha1Mismatch", + "downloaderEvents", + "runner_recordsStartFailure", "individualCancel", + "addDownload_queues", "addDownload_badArgs", + "clear_emptiesInPlace" + } + } + + Common: { + _description: "Tests for the Common base class providing shared utilities and enums across DependencyControl components." + + capitalizeTerms: (ut) -> + ut\assertEquals DepCtrl.terms.capitalize("hello world"), "Hello world" + + -- validateNamespace: pure computation, no stubs needed + + validateNamespace_valid: (ut) -> + result, err = Common.validateNamespace "l0.DependencyControl" + ut\assertTrue result + ut\assertNil err + + validateNamespace_multiPart: (ut) -> + result, err = Common.validateNamespace "a.b.c" + ut\assertTrue result + ut\assertNil err + + validateNamespace_noDot: (ut) -> + result, err = Common.validateNamespace "no-dot" + ut\assertFalse result + ut\assertString err + + validateNamespace_leadingDot: (ut) -> + result, err = Common.validateNamespace ".foo.bar" + ut\assertFalse result + ut\assertString err + + validateNamespace_trailingDot: (ut) -> + result, err = Common.validateNamespace "foo.bar." + ut\assertFalse result + ut\assertString err + + validateNamespace_invalidChars: (ut) -> + result, err = Common.validateNamespace "foo bar.baz" + ut\assertFalse result + ut\assertString err + + 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_consecutiveDots" + } + } + + CommonExtra: (controls\requireTest "Common") basePath + + 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) -> + -- ".." is now resolved rather than rejected + result = FileOps.validateFullPath {basePath, "..", "escape.txt"} + ut\assertString result -- resolves to parent dir + escape.txt + + validateFullPath_tooLong: (ut) -> + -- 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 + result = FileOps.validateFullPath {basePath, "with.txt"} + ut\assertNil result + + validateFullPath_reservedNames: (ut) -> + return unless isWindows + result = FileOps.validateFullPath {basePath, "CON", "file.txt"} + ut\assertNil result + + validateFullPath_reservedNameWithExt: (ut) -> + return unless isWindows + result = FileOps.validateFullPath {basePath, "NUL.txt"} + ut\assertNil result + + validateFullPath_trailingDotSegment: (ut) -> + result = FileOps.validateFullPath {basePath, "trailingdot.", "file.txt"} + ut\assertNil 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 + + 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) -> + path, err = FileOps.getNamespacedPath basePath, "l0.DependencyControl.Test", ".lua" + ut\assertNil err + ut\assertString path + ut\assertContains path, FileOps.joinPath "l0", "DependencyControl", "Test.lua" + + getNamespacedPath_flat: (ut) -> + path, err = FileOps.getNamespacedPath basePath, "l0.DependencyControl", ".lua", false + ut\assertNil err + ut\assertString path + ut\assertContains path, "l0.DependencyControl.lua" + + getNamespacedPath_badNamespace: (ut) -> + path, err = FileOps.getNamespacedPath basePath, "not-a-namespace", ".lua" + ut\assertNil path + ut\assertString err + + getNamespacedPath_badBasePath: (ut) -> + path, err = FileOps.getNamespacedPath {"relative", "path"}, "l0.DependencyControl", ".lua" + ut\assertNil path + ut\assertString err + + -- attributes: stubs lfs.attributes + -- lfs.attributes(path, key) returns (value) on success, (nil) when not found, + -- or (nil, errmsg) on error. FileOps.attributes maps these to value/false/nil. + + attributes_file: (ut) -> + attrStub = (ut\stub lfs, "attributes")\calls (path, key) -> "file" + mode, fullPath = FileOps.attributes {basePath, "file.txt"}, "mode" + ut\assertEquals mode, "file" + ut\assertString fullPath + attrStub\assertCalledOnceWith FileOps.joinPath(basePath, "file.txt"), "mode" + + attributes_notFound: (ut) -> + attrStub = (ut\stub lfs, "attributes")\calls (path, key) -> nil + mode, fullPath = FileOps.attributes {basePath, "missing.txt"}, "mode" + ut\assertFalse mode + ut\assertString fullPath + attrStub\assertCalledOnceWith FileOps.joinPath(basePath, "missing.txt"), "mode" + + -- joinPath: pure computation, no stubs needed + + joinPath_segmentsArray: (ut) -> + result = FileOps.joinPath {"path", "to", "file.txt"} + ut\assertEquals result, "path#{pathSep}to#{pathSep}file.txt" + + joinPath_segmentsVarargs: (ut) -> + result = FileOps.joinPath "path", "to", "file.txt" + ut\assertEquals result, "path#{pathSep}to#{pathSep}file.txt" + + joinPath_segmentsMixed: (ut) -> + result = FileOps.joinPath {"path", "to"}, "file.txt" + ut\assertEquals result, "path#{pathSep}to#{pathSep}file.txt" + + -- mkdir: stubs lfs.attributes + lfs.mkdir + + mkdir_new: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> nil + mkdirStub = (ut\stub lfs, "mkdir")\calls (path) -> true + result, path = FileOps.mkdir {basePath, "newdir"} + ut\assertTrue result + ut\assertString path + mkdirStub\assertCalledOnce! + + mkdir_exists: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "directory" + result, dir = FileOps.mkdir {basePath, "existing"} + ut\assertFalse result + ut\assertString dir + + -- readFile: stubs lfs.attributes + io.open + + readFile_success: (ut) -> + filePath = FileOps.joinPath basePath, "file.txt" + content = "hello, DependencyControl" + mockHandle = { + read: (handle, fmt) -> content + close: (handle) -> + } + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + openStub = (ut\stub io, "open")\calls (path, mode) -> mockHandle + data, err = FileOps.readFile filePath + ut\assertEquals data, content + ut\assertNil err + openStub\assertCalledOnceWith filePath, "rb" + + readFile_isDirectory: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "directory" + data, err = FileOps.readFile {basePath, "dir"} + ut\assertNil data + ut\assertString err + + -- getHash / verifyHash: stub readFile so the hash is computed over known content + + getHash_sha1: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns "abc" + ut\assertEquals FileOps.getHash("/path/file", "sha1"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + + getHash_defaultsToSha1: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns "abc" + ut\assertEquals FileOps.getHash("/path/file"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + + getHash_unsupportedType: (ut) -> + hash, err = FileOps.getHash "/path/file", "md5" + ut\assertNil hash + ut\assertString err + + verifyHash_match: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns "abc" + ut\assertTrue FileOps.verifyHash "/path/file", "A9993E364706816ABA3E25717850C26C9CD0D89D", "sha1" + + verifyHash_mismatch: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns "abc" + ok, err = FileOps.verifyHash "/path/file", ("0")\rep(40), "sha1" + ut\assertFalse ok + ut\assertString err + + verifyHash_badArg: (ut) -> + ok, err = FileOps.verifyHash "/path/file", nil + ut\assertNil ok + ut\assertString err + + -- copy: stubs lfs.attributes + io.open + + copy_success: (ut) -> + srcPath = FileOps.joinPath basePath, "src.txt" + dstPath = FileOps.joinPath basePath, "dst.txt" + mockIn = { + read: (handle, fmt) -> "content" + close: (handle) -> + } + mockOut = { + write: (handle, data) -> true + close: (handle) -> + } + (ut\stub lfs, "attributes")\calls (path, key) -> + if path == srcPath then "file" else nil + ioStub = (ut\stub io, "open")\calls (path, mode) -> + if mode == "rb" then mockIn else mockOut + result, err = FileOps.copy srcPath, dstPath + ioStub\assertCalledTimes 2 + ioStub\assertNthCalledWith 1, srcPath, "rb" + ioStub\assertNthCalledWith 2, dstPath, "wb" + ut\assertTrue result + ut\assertNil err + + copy_targetExists: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + result, err = FileOps.copy {basePath, "src.txt"}, {basePath, "dst.txt"} + ut\assertFalse result + ut\assertString err + + -- move: stubs lfs.attributes + os.remove + os.rename + + move_overwrite: (ut) -> + srcPath = FileOps.joinPath basePath, "src.txt" + dstPath = FileOps.joinPath basePath, "dst.txt" + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + removeStub = (ut\stub os, "remove")\returns true + renameStub = (ut\stub os, "rename")\returns true + result, err = FileOps.move srcPath, dstPath, true + ut\assertTrue result + ut\assertNil err + removeStub\assertCalledOnceWith dstPath + renameStub\assertCalledOnceWith srcPath, dstPath + + -- remove: stubs lfs.attributes + os.remove + + remove_success: (ut) -> + filePath = FileOps.joinPath basePath, "file.txt" + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + removeStub = (ut\stub os, "remove")\returns true + result, details = FileOps.remove filePath + ut\assertTrue result + removeStub\assertCalledOnceWith filePath + + remove_notFound: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> nil + result, details = FileOps.remove FileOps.joinPath basePath, "missing.txt" + ut\assertTrue result + ut\assertTable details + + _order: { + "validateFullPath_nonString", "validateFullPath_parentDir", "validateFullPath_tooLong", + "validateFullPath_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", + "validateFullPath_homeDirExpansion", "validateFullPath_reservedNameNonWindows", + "getNamespacedPath_nested", "getNamespacedPath_flat", + "getNamespacedPath_badNamespace", "getNamespacedPath_badBasePath", + "attributes_file", "attributes_notFound", + "mkdir_new", "mkdir_exists", + "readFile_success", "readFile_isDirectory", + "getHash_sha1", "getHash_defaultsToSha1", "getHash_unsupportedType", + "verifyHash_match", "verifyHash_mismatch", "verifyHash_badArg", + "copy_success", "copy_targetExists", + "move_overwrite", + "remove_success", "remove_notFound" + } + } + + FileOpsExtra: (controls\requireTest "FileOps") basePath, isWindows + + + 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 TIMER_MODULE_NAME, "sleep" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + state, timePassed = lock\lock 0 + ut\assertEquals state, Lock.LockState.Unavailable + tryLockStub\assertCalledOnce! + sleepStub\assertNotCalled! -- timeout=0 suppresses sleep + + lock_retry: (ut) -> + callCount = 0 + tryLockStub = (ut\stub BADMUTEX_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 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 TIMER_MODULE_NAME, "sleep" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + state, timePassed = lock\tryLock! + ut\assertEquals state, Lock.LockState.Unavailable + tryLockStub\assertCalledOnce! + + -- release + + release_held: (ut) -> + (ut\stub 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][DEPCTRL_DUMMY_MODULE_MARKER] + 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] = {[DEPCTRL_DUMMY_MODULE_MARKER]: 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] = {[DEPCTRL_DUMMY_MODULE_MARKER]: 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: ->} + 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 + + -- 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[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = rec + ut\assertIs Record\getRecord(ns), rec + + registry_getMissing: (ut) -> + ut\assertNil Record\getRecord uniqueName "absent" + + registry_getSkipsVirtual: (ut) -> + ns = uniqueName "virtns" + _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) + -- becomes visible through getRecord + registry_returnsAfterUnvirtualized: (ut) -> + ns = uniqueName "virtns" + rec = {namespace: ns, virtual: true} + _G[DEPCTRL_RECORDS_GLOBAL_KEY][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", + "validateNamespace_valid", "validateNamespace_invalid_noDot", + "validateNamespace_invalid_trailingDot", "validateNamespace_virtual", + "uninstall_virtual", "uninstall_unmanaged", + "getSubmodules_virtual", "getSubmodules_unmanaged", "getSubmodules_nonModule", + "getConfigFileName_basic", "registerMacro_basic", + "registry_getReturnsRegistered", "registry_getMissing", + "registry_getSkipsVirtual", "registry_returnsAfterUnvirtualized" + } + } + + 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" + } + } + + -- 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")! + + JsonSchema: (controls\requireTest "JsonSchema") basePath + + 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/l0/DependencyControl/test/Common.moon b/modules/l0/DependencyControl/test/Common.moon new file mode 100644 index 0000000..d5511bc --- /dev/null +++ b/modules/l0/DependencyControl/test/Common.moon @@ -0,0 +1,127 @@ +-- 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" + + -- 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", + "getObjectHash_isHexString", "getObjectHash_deterministic", "getObjectHash_ignoresKeyOrder", + "getObjectHash_nestedOrderIndependent", "getObjectHash_distinguishesContent", + "getObjectHash_typeTagged" + } + } 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/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..81e9ff7 --- /dev/null +++ b/modules/l0/DependencyControl/test/ModuleProvider.moon @@ -0,0 +1,70 @@ +-- 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) -> + 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}" + + { + _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: constants.DEPCTRL_NAME}), ref + + -- an uninitialized module (raw .version) gets its initializer run with the DepCtrl class + runInitializer_runsWhenUninitialized: (ut) -> + 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: 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 + + _order: { + "register_andGetProvider", "register_firstWins", + "registerRecord_normalizesAliases", "searcher_resolvesAliasToProvider", + "runInitializer_noInitHook", "runInitializer_runsWhenUninitialized", + "runInitializer_skipsWhenInitialized" + } + } 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..ae2471c --- /dev/null +++ b/modules/l0/DependencyControl/test/UpdateFeed.moon @@ -0,0 +1,269 @@ +-- 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 + } + -- 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} + 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 + ensureLoadedStub\assertCalledOnce! + + -- 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", 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" + 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 + + -- 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_passesThroughLocalFilePath", + "deployFiles_copiesToDist", "deployFiles_skipExistingNoClobber", + "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/DependencyControl/test/helpers/MockHttpServerController.moon b/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon new file mode 100644 index 0000000..661d2f1 --- /dev/null +++ b/modules/l0/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/l0/DependencyControl/test/helpers/mock-http-server.moon b/modules/l0/DependencyControl/test/helpers/mock-http-server.moon new file mode 100644 index 0000000..471de57 --- /dev/null +++ b/modules/l0/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! diff --git a/modules/l0/dkjson.moon b/modules/l0/dkjson.moon new file mode 100644 index 0000000..ad87a94 --- /dev/null +++ b/modules/l0/dkjson.moon @@ -0,0 +1,128 @@ +-- DependencyControl wrapper around the vendored upstream dkjson. +-- +-- 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, 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 +-- 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" + 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/l0/dkjson/vendor/dkjson.lua b/modules/l0/dkjson/vendor/dkjson.lua new file mode 100644 index 0000000..862eea9 --- /dev/null +++ b/modules/l0/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 diff --git a/schemas/feed/v0.4.0.json b/schemas/feed/v0.4.0.json new file mode 100644 index 0000000..e4c91e7 --- /dev/null +++ b/schemas/feed/v0.4.0.json @@ -0,0 +1,276 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$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", "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).", + "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." + }, + "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" + }, + "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_-]+)+$", + "lpegPattern": "[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'), or null to indicate an unreleased/pending build.", + "anyOf": [ + { "type": "string", "format": "date" }, + { "type": "string", "format": "date-time" }, + { "type": "null" } + ] + }, + + "SemanticVersionWithoutLabels": { + "description": "Semantic version string *without* pre-release or build metadata (..).", + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "lpegPattern": "%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}$", + "lpegPattern": "[0-9A-Fa-f]+ !.", + "minLength": 40, + "maxLength": 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}" + }, + "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." + }, + "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"] + }, + "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", + "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." + }, + "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", + "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." + }, + "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", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/Release" } + }, + "changelog": { + "$ref": "#/$defs/Changelog" + } + } + } + } +}