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..099d185 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": "598076DC6C112BCF5CD3EF0058F9D20E7C3F7B08" } ], "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,333 @@ ], "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": [ + "Centralized automatic update scheduling for all installed scripts, including modules not loaded by any automation script, eliminating redundant per-environment checks at startup.", + "Unit test menus for installed modules with a DependencyControl test suite are now registered automatically at startup." ] } } }, "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": { + "script": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}", + "test": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{namespacePath}/test" + }, + "localFileBasePath": { + "script": "@{localFileBasePath}modules/@{namespacePath}", + "test": "@{localFileBasePath}modules/@{namespacePath}/test" + }, "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": "F54C44CC727BA42DFF58A964159B6D9EADEB01F2" + }, + { + "name": "/Common.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "3826E8455751D04864823D04399E86A51C0F2F39" + }, + { + "name": "/Constants.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "80C42CB6088B92218AFCFD41E63DEE742E5D0882" }, { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "97BCD3207FE8158261FA7851057464535FCEFBC6" + "sha1": "30E8728C8D07057E5F00E1A220BBE38F5D167DF5" + }, + { + "name": "/ConfigView.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "9CF75E1BA571B95A550125FAD1CAD7053268097E" + }, + { + "name": "/Crypto.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "3AE799E87F69321077CF7C05FC42FFCEADEF4A05" + }, + { + "name": "/Downloader.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "B5051F30040C2BE95789D836D95BCBB68DD5D6D5" + }, + { + "name": "/Enum.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "0C60962AF286FAEB3E3326A004C18A8E24EF7EA4" + }, + { + "name": "/EventEmitter.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "ECD301C2EA0A74111981A6A0E2EAC6ADB2B32AB9" + }, + { + "name": "/FileLock.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "F6CB3F535AC17E783687BA629FD6D672555AB41B" }, { "name": "/FileOps.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "D999D34DB93BA76EF0E991CEB1CD63F5CC5F8E68" + "sha1": "0BE6B42CAD0184AC05A3A0ABA312C7491D28A9B6" + }, + { + "name": "/GitRepository.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "2D23AAA1BD2A16555F3374253A36475114E2F650" + }, + { + "name": "/JsonSchema.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "9E0E9AE5249B1B7112D4A5B6EE42CD80C42F6D19" + }, + { + "name": "/Lock.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "FC8A3B58F4A6FEE4ADFBBFD4343EE862580C9BAB" }, { "name": "/Logger.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1E479FE95F0DFBEE8B098302AB589F32D0C40A00" + "sha1": "1315A1B66FCA9E850F95BCB529174F2ABEADAFD9" + }, + { + "name": "/ModuleLoader.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "75997D551402B8673213F6F9A07DDC6FF132388A" + }, + { + "name": "/ModuleProvider.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "1D0B293D563BE18FA2685966FC56E51683065FB8" + }, + { + "name": "/NamedSemaphore.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "CAD7BF0E840581EF16A62859A0A957CFADCFE57D" + }, + { + "name": "/Record.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "FB54FCF8843C4C25FB8D49D585091F379A9D776E" + }, + { + "name": "/ScriptTargetFilter.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "6A3E34EA91136A0B42F9A3A0F626D520B344F144" + }, + { + "name": "/ScriptUpdateRecord.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "1C29F3462BBC010898528F9A072202FFAD1CF18A" + }, + { + "name": "/SemanticVersioning.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "12C2DF09B3681AC28C6F53A5A23A2DB9B956885F" + }, + { + "name": "/Stub.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "EBBE07FF72CD2DA24A71424087EA31947073B0AC" + }, + { + "name": "/BadMutex.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "6B1C4E43BB588DDD2DE0FF730F3F288C641C535E", + "delete": true + }, + { + "name": "/Timer.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "BCE7AEB26FBCA73276DB61A0C86BFDD1F7569B95" }, { "name": "/UnitTestSuite.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "ADAB6EFB05E08A7828DCA01BC1FC43D6482979A1" + "sha1": "4684062614EB98F585917C1F18D2BDE17609B7ED" }, { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1EE16D9D551FF82C2D7E448F2CD980E528874108" + "sha1": "217CFF60C8B6A87CC6074EEE51AB7B74F664CDEB" }, { "name": "/Updater.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "A4AE061724E68B2EFBB7495A477263E1746E228A" - } - ], - "requiredModules": [ + "sha1": "171A57DB3D7C553A5BD916FEC7B1EC8AD8B1CB7D" + }, { - "moduleName": "requireffi.requireffi", - "version": "0.1.1", - "feed": "@{feed:ffi-experiments}" + "name": "/ZipArchiver.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "0957979B1112933C9EF2B7AEE919F7CC884DCE78" + }, + { + "name": "/helpers/ffi-posix.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "972CE60ABA29D4BE64031C8E0FD087CA8EC59966" + }, + { + "name": "/helpers/ffi-windows.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "49B3E5CAFF38C141FB52D950000F785F5D91DFA4" + }, + { + "name": "/shims/BadMutex.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "269B7FE920886A1A294A71305CFD4974F6EF3F48" + }, + { + "name": "/shims/DownloadManager.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "F5E25C2678ABFAA7055DB6F99E58E44F7C454E0A" + }, + { + "name": "/shims/PreciseTimer.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "F976BF883F90623D4D71191F1A5E72703A8551BE" + }, + { + "name": ".moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "FE85119FE24E944A19E1185D0E8B7D6AFF840169", + "type": "test" }, { - "moduleName": "DM.DownloadManager", - "version": "0.3.1", - "feed": "@{feed:ffi-experiments}" + "name": "/BadMutex.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "BDEA61A6DD65512495FD919754E8E14D351A48EE", + "type": "test" }, { - "moduleName": "BM.BadMutex", - "version": "0.1.3", - "feed": "@{feed:ffi-experiments}" + "name": "/Common.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "B732E2879AD3F80A941771FF3783BC3CE58E625E", + "type": "test" }, { - "moduleName": "PT.PreciseTimer", - "version": "0.1.5", - "feed": "@{feed:ffi-experiments}" + "name": "/ffi-posix.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "535A053FE09841BDB4C757A77B7FB3EA360EA745", + "type": "test" + }, + { + "name": "/FileOps.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "50021BFAB752397D1658158914187CFADEBFAC80", + "type": "test" + }, + { + "name": "/GitRepository.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "F7E43D74EF6574408DFD1B851EF40F18BDB64FFA", + "type": "test" + }, + { + "name": "/JsonSchema.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "7785C25F8060AD519EFA2BF9D9E41BF9D984A43A", + "type": "test" + }, + { + "name": "/Lock.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "7AE20FBFD526FE42965AADDD738A88BDD143E8F6", + "type": "test" + }, + { + "name": "/ModuleProvider.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "19B4A2217D237F9803DE6C6E1B6A486BE45EED73", + "type": "test" + }, + { + "name": "/Record.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "C93745C8F984FD18965B711A2A63BD6891CC8D85", + "type": "test" + }, + { + "name": "/ScriptTargetFilter.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "278632008FE11ADB967B4761A2BFD1AB344C12B8", + "type": "test" + }, + { + "name": "/Timer.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "9666CC4536FE09F6382EEDC5C2580280F7912111", + "type": "test" + }, + { + "name": "/UpdateFeed.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "9C0FF19921BFECDC3B4FAF561FFF3617CA979D59", + "type": "test" + }, + { + "name": "/Updater.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "FDEDD2360CBDD793B83BD1C4DF8307EDE65E1F1C", + "type": "test" + }, + { + "name": "/helpers/mock-http-server.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "D6D87B5EA2F188EA5FF6D9479412FF5F9CE99EAE", + "type": "test" + }, + { + "name": "/helpers/MockHttpServerController.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "C699B32FF02A9600C0C42DE90402EAB99A8A474A", + "type": "test" } ] } }, "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 +400,75 @@ "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.", + "Automatic update scheduling is now centralized in the DependencyControl Toolbox and runs in a single Aegisub environment at startup, covering all installed scripts including modules not loaded by any automation script. Previously, each automation script's environment scheduled redundant checks for every module it loaded.", + "Record: Added `getAllRegisteredRecords()` to expose the full live record registry to tooling.", + "Modules with a DependencyControl unit test suite now have their test menus registered automatically by the Toolbox. Automation scripts register their own test menus when they call `registerMacros` through DependencyControl.", + "Lock: Locks are now per-resource — distinct namespace/resource pairs can be held at the same time instead of contending over a single global mutex — and accept an optional `Global` scope that enforces mutual exclusion across separate Aegisub instances (now used when reading and writing shared config files). A Global lock is backed by an OS advisory file lock that the system releases automatically if the holder crashes, so a crashed process can never leave a config file permanently locked.", + "Lock: While a lock is held it records its holder (name, process id, and lease expiry) in a side file for troubleshooting, and logs a warning when it is waiting on a holder whose lease has lapsed (a likely crash or stall). The recorded lease is honored by waiters; an `overrideExpiry` option lets a waiter apply its own expiry instead.", + "Lock: Added `renew(threshold)` to extend a held lock's lease during long operations (refreshing only when the remaining lease drops below the threshold, so it is cheap to call from a busy loop), and a `Lock.guard` helper that acquires a lock, runs a function, and always releases it (even if the function errors).", + "Updater: The in-progress-update flag is no longer stored in the config file; concurrent updates are now coordinated by a dedicated cross-process lock that the system releases automatically if an updater crashes. The lease is renewed throughout long downloads, and a new `Updater.isRunning()` reports whether (and which script) an update is currently running.", + "Timer: Exposed a shared monotonic `Timer.getTime()` clock, added stopwatch-style `start()`, `stop()`, and `reset()` methods, and PT.PreciseTimer is now provided through the same bundled-provider mechanism as BM.BadMutex and DM.DownloadManager (set DEPCTRL_FORCE_BUILTIN_TIMER=1 to force DepCtrl's implementation).", + "Updater: Scripts and modules installed in Aegisub's data automation directory (e.g. native modules installed by a system package manager) are no longer updated by DependencyControl — scheduled checks skip them with a debug log, while manual installs/updates and dependency resolution report a descriptive error. Portable / \"Local Config\" setups, where the user and data directories are the same, are unaffected." + ] + } + }, + "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": "73EDA9FC07D0F6B4B3713F08262C337B520B69B9" + }, + { + "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..a83fc50 --- /dev/null +++ b/depctrl.lua @@ -0,0 +1,374 @@ +#!/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 + + local DepCtrl = 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 = xpcall(require, debug.traceback, ns) + local record = okRequire and DepCtrl:getRegisteredRecord(ns) 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 + if record.testSuiteLoadError then + io.stderr:write(("! %s: test suite failed to load (%s)\n"):format(ns, tostring(record.testSuiteLoadError))) + failed = failed + 1 + else + io.stderr:write(("~ %s: no test suite found, skipping\n"):format(ns)) + end + 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..deecbcb 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -1,24 +1,35 @@ 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" } + scheduleUpdatesAndRegisterTests: { + moduleLoadFailed: "Couldn't load module '%s' to schedule updates/register its tests: %s" + registerMacrosError: "Error registering test macros for module '%s': %s" + scheduleError: "Unexpected error scheduling update for record '%s': %s" + } macroConfig: { hints: { customMenu: "Lets you sort your automation macros into submenus. Use / to denote submenu levels." @@ -37,7 +48,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 +88,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 +103,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 +139,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 +151,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 +202,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" @@ -218,4 +237,33 @@ depRec\registerMacros{ {"Update Script", "Manually check and perform updates to any installed script.", update}, {"Uninstall Script", "Removes an automation script or module from your system.", uninstall}, {"Macro Configuration", "Lets you change per-automation script settings.", macroConfig}, -}, "DependencyControl" \ No newline at end of file +}, "DependencyControl" + +-- Force-loads all installed modules, then sweeps the live record registry to schedule +-- periodic update checks for every record and register unit test menus for modules. +-- Automation scripts schedule their own update checks on macro invocation and register +-- their own test menus within their own Aegisub environment. +scheduleUpdatesAndRegisterTests = -> + config = getConfig! + + for namespace in pairs (config.c.modules or {}) + success, err = pcall require, namespace + unless success + logger\trace msgs.scheduleUpdatesAndRegisterTests.moduleLoadFailed, namespace, tostring err + + for _, record in pairs DepCtrl\getAllRegisteredRecords! + success, errMsgOrErrCode, errDetail = pcall DepCtrl.updater\scheduleUpdate, record + if not success + logger\trace msgs.scheduleUpdatesAndRegisterTests.scheduleError, record.name or record.namespace, errMsgOrErrCode + elseif errMsgOrErrCode < 0 + logger\trace msgs.scheduleUpdatesAndRegisterTests.scheduleError, record.name or record.namespace, + DepCtrl.updater\getUpdaterErrorMsg errMsgOrErrCode, record.name or record.namespace, record.scriptType, false, errDetail + + if record.tests and record.scriptType == DepCtrl.ScriptType.Module + success, errMsg = pcall record.tests\registerMacros + unless success + logger\trace msgs.scheduleUpdatesAndRegisterTests.registerMacrosError, record.name or record.namespace, errMsg + + DepCtrl.updater\releaseLock! + +scheduleUpdatesAndRegisterTests! 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/Record.moon b/modules/DependencyControl/Record.moon deleted file mode 100644 index 5293321..0000000 --- a/modules/DependencyControl/Record.moon +++ /dev/null @@ -1,315 +0,0 @@ -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 diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon deleted file mode 100644 index 64b9301..0000000 --- a/modules/DependencyControl/UnitTestSuite.moon +++ /dev/null @@ -1,843 +0,0 @@ - -Logger = require "l0.DependencyControl.Logger" -re = require "aegisub.re" --- make sure tests can be loaded from the test directory -package.path ..= aegisub.decode_path("?user/automation/tests") .. "/?.lua;" - ---- A class for all single unit tests. --- Provides useful assertion and logging methods for a user-specified test function. --- @classmod UnitTest -class UnitTest - @msgs = { - run: { - setup: "Performing setup... " - teardown: "Performing teardown... " - test: "Running test '%s'... " - ok: "OK." - failed: "FAILED!" - reason: "Reason: %s" - } - new: { - badTestName: "Test name must be of type %s, got a %s." - } - - assert: { - true: "Expected true, actual value was %s." - false: "Expected false, actual value was %s." - nil: "Expected nil, actual value was %s." - notNil: "Got nil when a value was expected." - truthy: "Expected a truthy value, actual value was falsy (%s)." - falsy: "Expected a falsy value, actual value was truthy (%s)." - type: "Expected a value of type %s, actual value was of type %s." - sameType: "Type of expected value (%s) didn't match type of actual value (%s)." - inRange: "Expected value to be in range [%d .. %d], actual value %d was %s %d." - almostEquals: "Expected value to be almost equal %d ± %d, actual value was %d." - notAlmostEquals: "Expected numerical value to not be close to %d ± %d, actual value was %d." - checkArgTypes: "Expected argument #%d (%s) to be of type %s, got a %s." - zero: "Expected 0, actual value was a %s." - notZero: "Got a 0 when a number other than 0 was expected." - compare: "Expected value to be a number %s %d, actual value was %d." - integer: "Expected numerical value to be an integer, actual value was %d." - positiveNegative: "Expected a %s number (0 %s), actual value was %d." - equals: "Actual value didn't match expected value.\n%s actual: %s\n%s expected: %s" - notEquals: "Actual value equals expected value when it wasn't supposed to:\n%s actual: %s" - is: "Expected %s, actual value was %s." - isNot: "Actual value %s was identical to the expected value when it wasn't supposed to." - itemsEqual: "Actual item values of table weren't %s to the expected values (checked %s):\n Actual: %s\nExpected: %s" - itemsEqualNumericKeys: "only continuous numerical keys" - itemsEqualAllKeys: "all keys" - 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" - errorMsgMatches: "Error message '%s' didn't match expected %s pattern '%s'." - } - - formatTemplate: { - type: "'%s' of type %s" - } - } - - --- Creates a single unit test. - -- Instead of calling this constructor you'd usually provide test data - -- in a table structure to @{UnitTestSuite:new} as an argument. - -- @tparam string name a descriptive title for the test - -- @tparam function(UnitTest, ...) testFunc the function containing the test code - -- @tparam UnitTestClass testClass the test class this test belongs to - -- @treturn UnitTest the unit test - -- @see UnitTestSuite:new - new: (@name, @f = -> , @testClass) => - @logger = @testClass.logger - error type(@logger) unless type(@logger) == "table" - @logger\assert type(@name) == "string", @@msgs.new.badTestName, type @name - - --- Runs the unit test function. - -- In addition to the @{UnitTest} object itself, it also passes - -- the specified arguments into the function. - -- @param[opt] args any optional modules or other data the test function needs - -- @treturn[1] boolean true (test succeeded) - -- @treturn[2] boolean false (test failed) - -- @treturn[2] string the error message describing how the test failed - run: (...) => - @assertFailed = false - @logStart! - @success, res = xpcall @f, debug.traceback, @, ... - @logResult res - - return @success, @errMsg - - --- Formats and writes a "running test x" message to the log. - -- @local - logStart: => - @logger\logEx nil, @@msgs.run.test, false, nil, nil, @name - - --- Formats and writes the test result to the log. - -- In case of failure the message contains details about either the test assertion that failed - -- or a stack trace if the test ran into a different exception. - -- @local - -- @tparam[opt=errMsg] the error message being logged; defaults to the error returned by the last run of this test - logResult: (errMsg = @errMsg) => - if @success - @logger\logEx nil, @@msgs.run.ok, nil, nil, 0 - else - if @assertFailed - -- scrub useless stack trace from asserts provided by this module - errMsg = errMsg\gsub "%[%w+ \".-\"%]:%d+:", "" - errMsg = errMsg\gsub "stack traceback:.*", "" - @errMsg = errMsg - @logger\logEx nil, @@msgs.run.failed, nil, nil, 0 - @logger.indent += 1 - @logger\log @@msgs.run.reason, @errMsg - @logger.indent -= 1 - - --- Formats a message with a specified predefined template. - -- Currently only supports the "type" template. - -- @local - -- @tparam string template the name of the template to use - -- @param[opt] args any arguments required for formatting the message - format: (tmpl, ...) => - inArgs = table.pack ... - outArgs = switch tmpl - when "type" then {tostring(inArgs[1]), type(inArgs[1])} - - @@msgs.formatTemplate[tmpl]\format unpack outArgs - - - -- static helper functions - - --- Compares equality of two specified arguments - -- Requirements for values are considered equal: - -- [1] their types match - -- [2] their metatables are equal - -- [3] strings and numbers are compared by value - -- functions and cdata are compared by reference - -- tables must have equal values at identical indexes and are compared recursively - -- (i.e. two table copies of `{"a", {"b"}}` are considered equal) - -- @static - -- @param a the first value - -- @param b the second value - -- @tparam[opt] string aType if already known, specify the type of the first value - -- 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 - - - --- Compares equality of two specified tables ignoring table keys. - -- The table comparison works much in the same way as @{UnitTest:equals}, - -- however this method doesn't require table keys to be equal between a and b - -- and considers two tables to be equal if an equal value is found in b for every value in a and vice versa. - -- By default this only looks at numerical indexes - -- as this kind of comparison doesn't usually make much sense for hashtables. - -- @static - -- @tparam table a the first table - -- @tparam table b the second table - -- @tparam[opt=true] bool onlyNumericalKeys Disable this option to also compare items with non-numerical keys - -- at the expense of a performance hit. - -- @tparam[opt=false] bool ignoreExtraAItems Enable this option to make the comparison one-sided, - -- 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 - - --- Helper method to mark a test as failed by assertion and throw a specified error message. - -- @local - -- @param condition passing in a falsy value causes the assertion to fail - -- @tparam string message error message (may contain format string templates) - -- @param[opt] args any arguments required for formatting the message - assert: (condition, ...) => - args = table.pack ... - msg = table.remove args, 1 - unless condition - @assertFailed = true - @logger\logEx 1, msg, nil, nil, 0, unpack args - - - -- type assertions - - --- Fails the assertion if the specified value didn't have the expected type. - -- @param value the value to be type-checked - -- @tparam string expectedType the expected type - assertType: (val, expected) => - @checkArgTypes val: {val, "_any"}, expected: {expected, "string"} - actual = type val - @assert actual == expected, @@msgs.assert.type, expected, actual - - --- Fails the assertion if the types of the actual and expected value didn't match - -- @param actual the actual value - -- @param expected the expected value - assertSameType: (actual, expected) => - actualType, expectedType = type(actual), type expected - @assert actualType == expectedType, @@msgs.assert.sameType, expectedType, actualType - - --- Fails the assertion if the specified value isn't a boolean - -- @param value the value expected to be a boolean - assertBoolean: (val) => @assertType val, "boolean" - --- Shorthand for @{UnitTest:assertBoolean} - assertBool: (val) => @assertType val, "boolean" - - --- Fails the assertion if the specified value isn't a function - -- @param value the value expected to be a function - assertFunction: (val) => @assertType val, "function" - - --- Fails the assertion if the specified value isn't a number - -- @param value the value expected to be a number - assertNumber: (val) => @assertType val, "number" - - --- Fails the assertion if the specified value isn't a string - -- @param value the value expected to be a string - assertString: (val) => @assertType val, "string" - - --- Fails the assertion if the specified value isn't a table - -- @param value the value expected to be a table - assertTable: (val) => @assertType val, "table" - - --- Helper method to type-check arguments as a prerequisite to other asserts. - -- @local - -- @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 - 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] - i += 1 - - - -- boolean asserts - - --- Fails the assertion if the specified value isn't the boolean `true`. - -- @param value the value expected to be `true` - assertTrue: (val) => - @assert val == true, @@msgs.assert.true, @format "type", val - - --- Fails the assertion if the specified value doesn't evaluate to boolean `true`. - -- In Lua this is only ever the case for `nil` and boolean `false`. - -- @param value the value expected to be truthy - assertTruthy: (val) => - @assert val, @@msgs.assert.truthy, @format "type", val - - --- Fails the assertion if the specified value isn't the boolean `false`. - -- @param value the value expected to be `false` - assertFalse: (val) => - @assert val == false, @@msgs.assert.false, @format "type", val - - --- Fails the assertion if the specified value doesn't evaluate to boolean `false`. - -- In Lua `nil` is the only other value that evaluates to `false`. - -- @param value the value expected to be falsy - assertFalsy: (val) => - @assert not val, @@msgs.assert.falsy, @format "type", val - - --- Fails the assertion if the specified value is not `nil`. - -- @param value the value expected to be `nil` - assertNil: (val) => - @assert val == nil, @@msgs.assert.nil, @format "type", val - - --- Fails the assertion if the specified value is `nil`. - -- @param value the value expected to not be `nil` - assertNotNil: (val) => - @assert val != nil, @@msgs.assert.notNil, @format "type", val - - - -- numerical asserts - - --- Fails the assertion if a number is out of the specified range. - -- @tparam number actual the number expected to be in range - -- @tparam number min the minimum (inclusive) value - -- @tparam number max the maximum (inclusive) value - assertInRange: (actual, min = -math.huge, max = math.huge) => - @checkArgTypes actual: {actual, "number"}, min: {min, "number"}, max: {max, "number"} - @assert actual >= min, @@msgs.assert.inRange, min, max, actual, "<", min - @assert actual <= max, @@msgs.assert.inRange, min, max, actual, ">", max - - --- Fails the assertion if a number is not lower than the specified value. - -- @tparam number actual the number to compare - -- @tparam number limit the lower limit (exclusive) - assertLessThan: (actual, limit) => - @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} - @assert actual < limit, @@msgs.assert.compare, "<", limit, actual - - --- Fails the assertion if a number is not lower than or equal to the specified value. - -- @tparam number actual the number to compare - -- @tparam number limit the lower limit (inclusive) - assertLessThanOrEquals: (actual, limit) => - @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} - @assert actual <= limit, @@msgs.assert.compare, "<=", limit, actual - - --- Fails the assertion if a number is not greater than the specified value. - -- @tparam number actual the number to compare - -- @tparam number limit the upper limit (exclusive) - assertGreaterThan: (actual, limit) => - @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} - @assert actual > limit, @@msgs.assert.compare, ">", limit, actual - - --- Fails the assertion if a number is not greater than or equal to the specified value. - -- @tparam number actual the number to compare - -- @tparam number limit the upper limit (inclusive) - assertGreaterThanOrEquals: (actual, limit) => - @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} - @assert actual >= limit, @@msgs.assert.compare, ">=", limit, actual - - --- Fails the assertion if a number is not in range of an expected value +/- a specified margin. - -- @tparam number actual the actual value - -- @tparam number expected the expected value - -- @tparam[opt=1e-8] number margin the maximum (inclusive) acceptable margin of error - assertAlmostEquals: (actual, expected, margin = 1e-8) => - @checkArgTypes actual: {actual, "number"}, min: {expected, "number"}, max: {margin, "number"} - - margin = math.abs margin - @assert math.abs(actual-expected) <= margin, @@msgs.assert.almostEquals, - expected, margin, actual - - --- Fails the assertion if a number differs from another value at most by a specified margin. - -- Inverse of @{assertAlmostEquals} - -- @tparam number actual the actual value - -- @tparam number value the value being compared against - -- @tparam[opt=1e-8] number margin the maximum (inclusive) margin of error for the numbers to be considered equal - assertNotAlmostEquals: (actual, value, margin = 1e-8) => - @checkArgTypes actual: {actual, "number"}, value: {value, "number"}, max: {margin, "number"} - - margin = math.abs margin - @assert math.abs(actual-value) > margin, @@msgs.assert.almostEquals, value, margin, actual - - --- Fails the assertion if a number is not equal to 0 (zero). - -- @tparam number actual the value - assertZero: (actual) => - @checkArgTypes actual: {actual, "number"} - @assert actual == 0, @@msgs.assert.zero, actual - - --- Fails the assertion if a number is equal to 0 (zero). - -- Inverse of @{assertZero} - -- @tparam number actual the value - assertNotZero: (actual) => - @checkArgTypes actual: {actual, "number"} - @assert actual != 0, @@msgs.assert.notZero - - --- Fails the assertion if a specified number has a fractional component. - -- All numbers in Lua share a common data type, which is usually a double, - -- which is the reason this is not a type check. - -- @tparam number actual the value - assertInteger: (actual) => - @checkArgTypes actual: {actual, "number"} - @assert math.floor(actual) == actual, @@msgs.assert.integer, actual - - --- Fails the assertion if a specified number is less than or equal 0. - -- @tparam number actual the value - -- @tparam[opt=false] boolean includeZero makes the assertion consider 0 to be positive - assertPositive: (actual, includeZero = false) => - @checkArgTypes actual: {actual, "number"}, includeZero: {includeZero, "boolean"} - res = includeZero and actual >= 0 or actual > 0 - @assert res, @@msgs.assert.positiveNegative, "positive", - includeZero and "included" or "excluded" - - --- Fails the assertion if a specified number is greater than or equal 0. - -- @tparam number actual the value - -- @tparam[opt=false] boolean includeZero makes the assertion not fail when a 0 is encountered - assertNegative: (actual, includeZero = false) => - @checkArgTypes actual: {actual, "number"}, includeZero: {includeZero, "boolean"} - res = includeZero and actual <= 0 or actual < 0 - @assert res, @@msgs.assert.positiveNegative, "positive", - includeZero and "included" or "excluded" - - - -- generic asserts - - --- Fails the assertion if a the actual value is not *equal* to the expected value. - -- On the requirements for equality see @{UnitTest:equals} - -- @param actual the actual value - -- @param expected the expected value - assertEquals: (actual, expected) => - @assert self.equals(actual, expected), @@msgs.assert.equals, type(actual), - @logger\dumpToString(actual), type(expected), @logger\dumpToString expected - - --- Fails the assertion if a the actual value is *equal* to the expected value. - -- Inverse of @{UnitTest:assertEquals} - -- @param actual the actual value - -- @param expected the expected value - assertNotEquals: (actual, expected) => - @assert not self.equals(actual, expected), @@msgs.assert.notEquals, - type(actual), @logger\dumpToString expected - - --- Fails the assertion if a the actual value is not *identical* to the expected value. - -- Uses the `==` operator, so in contrast to @{UnitTest:assertEquals}, - -- this assertion compares tables by reference. - -- @param actual the actual value - -- @param expected the expected value - assertIs: (actual, expected) => - @assert actual == expected, @@msgs.assert.is, @format("type", expected), - @format "type", actual - - --- Fails the assertion if a the actual value is *identical* to the expected value. - -- Inverse of @{UnitTest:assertIs} - -- @param actual the actual value - -- @param expected the expected value - assertIsNot: (actual, expected) => - @assert actual != expected, @@msgs.assert.isNot, @format "type", expected - - - -- table asserts - - --- Fails the assertion if the items of one table aren't *equal* to the items of another. - -- Unlike @{UnitTest:assertEquals} this ignores table keys, so e.g. two numerically-keyed tables - -- with equal items in a different order would still be considered equal. - -- By default this assertion only compares values at numerical indexes (see @{UnitTest:itemsEqual} for details). - -- @tparam table actual the first table - -- @tparam table expected the second table - -- @tparam[opt=true] boolean onlyNumericalKeys Disable this option to also compare items with non-numerical keys at the expense of a performance hit. - assertItemsEqual: (actual, expected, onlyNumKeys = true) => - @checkArgTypes { actual: {actual, "table"}, expected: {actual, "table"}, - onlyNumKeys: {onlyNumKeys, "boolean"} - } - - @assert self.itemsEqual(actual, expected, onlyNumKeys), - @@msgs.assert[onlyNumKeys and "itemsEqualNumericKeys" or "itemsEqualAllKeys"], - @logger\dumpToString(actual), @logger\dumpToString expected - - - --- Fails the assertion if the items of one table aren't *identical* to the items of another. - -- Like @{UnitTest:assertItemsEqual} this ignores table keys, however it compares table items by reference. - -- By default this assertion only compares values at numerical indexes (see @{UnitTest:itemsEqual} for details). - -- @tparam table actual the first table - -- @tparam table expected the second table - -- @tparam[opt=true] boolean onlyNumericalKeys Disable this option to also compare items with non-numerical keys - assertItemsAre: (actual, expected, onlyNumKeys = true) => - @checkArgTypes { actual: {actual, "table"}, expected: {actual, "table"}, - onlyNumKeys: {onlyNumKeys, "boolean"} - } - - @assert self.itemsEqual(actual, expected, onlyNumKeys, nil, true), - @@msgs.assert[onlyNumKeys and "itemsEqualNumericKeys" or "itemsEqualAllKeys"], - @logger\dumpToString(actual), @logger\dumpToString expected - - --- Fails the assertion if the numerically-keyed items of a table aren't continuous. - -- The rationale for this is that when iterating a table with ipairs or retrieving its length - -- with the # operator, Lua may stop processing the table once the item at index n is nil, - -- effectively hiding any subsequent values - -- @tparam table tbl the table to be checked - assertContinuous: (tbl) => - @checkArgTypes { tbl: {tbl, "table"} } - - realCnt, contCnt = 0, #tbl - for _, v in pairs tbl - if type(v) == "number" and math.floor(v) == v - realCnt += 1 - - @assert realCnt == contCnt, @@msgs.assert.continuous, contCnt+1, realCnt - - -- string asserts - - --- Fails the assertion if a string doesn't match the specified pattern. - -- Supports both Lua and Regex patterns. - -- @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 - - --- Fails the assertion if a string doesn't contain a specified substring. - -- Search is case-sensitive by default. - -- @tparam string str the input string - -- @tparam string needle the substring to be found - -- @tparam[opt=true] boolean caseSensitive Disable this option to use locale-dependent case-insensitive comparison. - -- @tparam[opt=1] number init the first byte to start the search at - assertContains: (str, needle, caseSensitive = true, init = 1) => - @checkArgTypes { str: {str, "string"}, needle: {needle, "string"}, - caseSensitive: {caseSensitive, "boolean"}, init: {init, "number"} - } - - _str, _needle = if caseSensitive - str\lower!, needle\lower! - else str, needle - @assert str\find(needle, init, true), str, needle, - caseSensitive and "sensitive" or "insensitive" - - -- function asserts - - - --- Fails the assertion if calling a function with the specified arguments doesn't cause it throw an error. - -- @tparam function func the function to be called - -- @param[opt] args any number of arguments to be passed into the function - assertError: (func, ...) => - @checkArgTypes { func: {func, "function"} } - - res = table.pack pcall func, ... - retCnt, success = res.n, table.remove res, 1 - res.n = nil - @assert success == false, @@msgs.assert.error, retCnt, @logger\dumpToString res - 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. - -- @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"} - } - 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 - - ---- A special case of the UnitTest class for a setup routine --- @classmod UnitTestSetup -class UnitTestSetup extends UnitTest - --- Runs the setup routine. - -- Only the @{UnitTestSetup} object is passed into the function. - -- Values returned by the setup routine are stored to be passed into the test functions later. - -- @treturn[1] boolean true (test succeeded) - -- @treturn[1] table retVals all values returned by the function packed into a table - -- @treturn[2] boolean false (test failed) - -- @treturn[2] string the error message describing how the test failed - run: => - @logger\logEx nil, @@msgs.run.setup, false - - res = table.pack pcall @f, @ - @success = table.remove res, 1 - @logResult res[1] - - if @success - @retVals = res - return true, @retVals - - return false, @errMsg - ---- A special case of the UnitTest class for a teardown routine --- @classmod UnitTestTeardown -class UnitTestTeardown extends UnitTest - --- Formats and writes a "running test x" message to the log. - -- @local - logStart: => - @logger\logEx nil, @@msgs.run.teardown, false - - ---- Holds a unit test class, i.e. a group of unit tests with common setup and teardown routines --- @classmod UnitTestClass -class UnitTestClass - msgs = { - run: { - runningTests: "Running test class '%s' (%d tests)..." - setupFailed: "Setup for test class '%s' FAILED, skipping tests." - 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." - testNotFound: "Couldn't find requested test '%s'." - } - } - - --- Creates a new unit test class complete with a number of unit test as well as optional setup and teardown. - -- Instead of calling this constructor directly, it is recommended to call @{UnitTestSuite:new} instead, - -- which takes a table of test functions and creates test classes automatically. - -- @tparam string name a descriptive name for the test class - -- @tparam[opt={}] {[string] = function|table, ...} args a table of test functions by name; - -- indexes starting with "_" have special meaning and are not added as regular tests: - -- * _setup: a @{UnitTestSetup} routine - -- * _teardown: a @{UnitTestTeardown} routine - -- * _order: alternative syntax to the order parameter (see below) - -- @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. - new: (@name, args = {}, @order, @testSuite) => - @logger = @testSuite.logger - @setup = UnitTestSetup "setup", args._setup, @ - @teardown = UnitTestTeardown "teardown", args._teardown, @ - @description = args._description - @order or= args._order - @tests = [UnitTest(name, f, @) for name, f in pairs args when "_" != name\sub 1,1] - - --- Runs all tests in the unit test class in the specified order. - -- @param[opt=false] abortOnFail stops testing once a test fails - -- @param[opt=(default)] overrides the default test order - -- @treturn[1] boolean true (test class succeeded) - -- @treturn[2] boolean false (test class failed) - -- @treturn[2] {@{UnitTest}, ...} a list of unit test that failed - run: (abortOnFail, order = @order) => - tests, failed = @tests, {} - if order - tests, mappings = {}, {test.name, test for test in *@tests} - for i, name in ipairs order - @logger\assert mappings[name], msgs.run.testNotFound, name - tests[i] = mappings[name] - testCnt, failedCnt = #tests, 0 - - @logger\log msgs.run.runningTests, @name, testCnt - @logger.indent += 1 - - success, res = @setup\run! - -- failing the setup always aborts - unless success - @logger.indent -= 1 - @logger\warn msgs.run.setupFailed, @name - return false, -1 - - 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 - - @logger.indent -= 1 - @success = failedCnt == 0 - - if @success - @logger\log msgs.run.success, @name - return true - - @logger\log msgs.run.testsFailed, @name, failedCnt, testCnt - return false, failed - - ---- 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 - msgs = { - run: { - running: "Running %d test classes for %s... " - aborted: "Aborting after %d test classes... " - classesFailed: "FAILED %d of %d test classes." - success: "All tests completed successfully." - classNotFound: "Couldn't find requested test class '%s'." - } - registerMacros: { - allDesc: "Runs the whole test suite." - } - new: { - badClassesType: "Test classes must be passed in either as a table or an import function, got a %s" - } - import: { - noTableReturned: "The test import function must return a table of test classes, got a %s." - } - } - - @UnitTest = UnitTest - @UnitTestClass = UnitTestClass - - --- Creates a complete unit test suite for a module or automation script. - -- Using this constructor will create all test classes and tests automatically. - -- @tparam string namespace the namespace of the module or automation script to test. - -- @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) - -- * 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. - -- indexes starting with "_" have special meaning and are not added as regular tests: - -- * _order: alternative syntax to the order parameter (see below) - -- @tparam [opt=nil (unordered)] {string, ...} An list of test class names in the desired execution order. - -- Only test classes mentioned in this table will be performed when running the whole test suite. - -- If unspecified, all test classes will be run in random order. - new: (@namespace, classes, @order) => - @logger = Logger defaultLevel: 3, fileBaseName: @namespace, fileSubName: "UnitTests", toFile: true - @classes = {} - switch type classes - when "table" then @addClasses classes - when "function" then @importFunc = classes - else @logger\error msgs.new.badClassesType, type classes - - --- Constructs test classes and adds them to the suite. - -- Use this if you need to add additional test classes to an existing @{UnitTestSuite} object. - -- @tparam {[string] = table, ...} args a hashtable of @{UnitTestClass} constructor tables by name. - addClasses: (classes) => - @classes[#@classes+1] = UnitTestClass(name, args, args._order, @) for name, args in pairs classes when "_" != name\sub 1,1 - if classes._order - @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. - import: (...) => - return false unless @importFunc - classes = self.importFunc ... - @logger\assert type(classes) == "table", msgs.import.noTableReturned, type classes - @addClasses classes - @importFunc = nil - - --- Registers macros for running all or specific test classes of this suite. - -- If the test script is placed in the appropriate directory (according to module/automation script namespace), - -- this is automatically handled by DependencyControl. - registerMacros: => - 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! - - --- Runs all test classes of this suite in the specified order. - -- @param[opt=false] abortOnFail stops testing once a test fails - -- @param[opt=(default)] overrides the default test order - -- @treturn[1] boolean true (test class succeeded) - -- @treturn[2] boolean false (test class failed) - -- @treturn[2] {@{UnitTest}, ...} a list of unit test that failed - run: (abortOnFail, order = @order) => - classes, allFailed = @classes, {} - if order - classes, mappings = {}, {cls.name, cls for cls in *@classes} - for i, name in ipairs order - @logger\assert mappings[name], msgs.run.classNotFound, name - classes[i] = mappings[name] - - classCnt, failedCnt = #classes, 0 - @logger\log msgs.run.running, classCnt, @namespace - @logger.indent += 1 - - for i, cls in pairs classes - success, failed = cls\run abortOnFail - unless success - failedCnt += 1 - allFailed[#allFailed+1] = test for test in *failed - if abortOnFail - @logger.indent -= 1 - @logger\warn msgs.run.abort, i - return false, allFailed - - @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 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..5bd9e08 --- /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 The directory to resolve the token to; nil/"" marks it unset. +---@return string? 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? 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..f499c0b --- /dev/null +++ b/modules/l0/DependencyControl.moon @@ -0,0 +1,84 @@ +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. +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.shims.BadMutex", {"BM.BadMutex"}, "DEPCTRL_FORCE_BUILTIN_MUTEX" +provideBundled "l0.DependencyControl.shims.DownloadManager", {"DM.DownloadManager"}, "DEPCTRL_FORCE_BUILTIN_DOWNLOADER" +provideBundled "l0.DependencyControl.shims.PreciseTimer", {"PT.PreciseTimer"}, "DEPCTRL_FORCE_BUILTIN_TIMER" + +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" + +---Main DependencyControl entry point. +---Provides package management and access to all sub-modules. +---@class DependencyControl: Record +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", +} +DependencyControl.__class.version = rec +LOADED_MODULES[rec.moduleName], package.loaded[rec.moduleName] = DependencyControl, DependencyControl +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..bb0383a --- /dev/null +++ b/modules/l0/DependencyControl/Common.moon @@ -0,0 +1,270 @@ +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 canonical 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 any The value to flatten. +---@param depth? number Maximum depth to flatten (default 1). +---@param toArrayTable? fun(value: any, valueType: string): table?, boolean? Converts a non-array value to an array table. +---@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? valid True when the namespace is well-formed. + ---@return string? err Validation error message when invalid. + @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/include") + 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. + ---@param a any + ---@param b any + ---@return boolean equal + @equals = _equals + + ---Compares table items for equality, ignoring keys. + ---By default only numerical indexes are compared. + ---@param a table + ---@param b table + ---@param onlyNumKeys? boolean Compare only sequential numeric indices (default true). + ---@param ignoreExtraAItems? boolean Allow `a` to contain items absent from `b` (default false). + ---@param requireIdenticalItems? boolean Require identical (not merely equal) table items (default false). + ---@return boolean equal + @itemsEqual = _itemsEqual + + ---Shallow-copies a table (no metatable). + ---@param tbl table The table to copy. + ---@return table copy 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 copy 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). + ---@param tbl table The table to deep-copy. + ---@return table copy 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 any The value to flatten. + ---@param depth? number Maximum depth to flatten (default 1). + ---@param toArrayTable? fun(value: any, valueType: string): table?, boolean? Converts a non-array value to an array table. + ---@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. + ---@param value any The value to hash. + ---@return string hash 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..a21a7b2 --- /dev/null +++ b/modules/l0/DependencyControl/ConfigHandler.moon @@ -0,0 +1,437 @@ +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 logger? Logger + ---@param noLoad? boolean Don't load the file immediately (default false). + ---@return ConfigHandler? handler + ---@return string? 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 defaults? table Default values for the hive. + ---@param logger? Logger + ---@return ConfigView? view + ---@return string? 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 filePath? string + ---@param 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 + -- config files are shared across concurrent Aegisub instances, so the lock + -- must exclude across processes, not just within this one + @lock = Lock namespace: "l0.DependencyControl.ConfigHandler", resource: @filePath, + holderName: @@__name, logger: @logger, scope: Lock.Scope.Global + + + 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 + + + -- copied from Aegisub util.moon, adjusted to skip private keys + ---Deep-copies a value while skipping private keys prefixed with "_". + ---@param val any + ---@return any copy + @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? hive + ---@return string? 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[]? views nil when targetView belongs to a different handler. + ---@return string? 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 defaults? table Default values for the hive. + ---@return ConfigView? view + ---@return string? 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 views? ConfigView|ConfigView[] Views to refresh (default: all registered views). + ---@param waitLockTime? number Seconds to wait for the config lock. + ---@return boolean? success + ---@return string? 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 views? ConfigView|ConfigView[] Views to merge (default: the whole config). + ---@param waitLockTime? number Seconds to wait for the config lock. + ---@return boolean? success + ---@return string? 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? hive + ---@return string? 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..7532ddc --- /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 Config file path, or false for an in-memory (orphan) view. + ---@param hivePath string|string[] The hive (nested key path) this view targets. + ---@param defaults? table Default values for the hive. + ---@param logger? Logger + ---@param noLoad? boolean Don't load the file immediately (default false). + ---@return ConfigView? view + ---@return string? 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 Backing handler, or nil for an in-memory (orphan) view. + ---@param hivePath string|string[] The hive (nested key path) this view targets. + ---@param defaults? table Default values for the hive. + 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 waitLockTime? number Seconds to wait for the config lock. + ---@return boolean? success + ---@return string? 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 tbl? table|ConfigView Source values. + ---@param keys? string[] Restrict the copy to these keys. + ---@param updateOnly? boolean Only overwrite keys already present in this view. + ---@param skipSameLengthTables? boolean Skip array values whose length matches the existing one. + ---@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? overlapping nil when the views belong to different handlers. + ---@return string? 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 waitLockTime? number Seconds to wait for the config lock. + ---@return boolean? success + ---@return string? 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? success + ---@return string? err + refresh: => + @userConfig, msg = @__configHandler\getHive @__hivePath + return if @userConfig + true + else nil, msg + + + ---Writes this view's hive to the config file. + ---@param waitLockTime? number Seconds to wait for the config lock. + ---@return boolean? success + ---@return string? 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..4cb6ea1 --- /dev/null +++ b/modules/l0/DependencyControl/Crypto.moon @@ -0,0 +1,181 @@ +-- 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. + +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 + +---Cryptographic / hashing utilities backed by a native SHA-1 where available. +---@class Crypto +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? digest A 40-character lowercase hex digest, or nil on invalid input. + ---@return string? 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/Downloader.moon b/modules/l0/DependencyControl/Downloader.moon new file mode 100644 index 0000000..0707b2a --- /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 + ffiWin = require "l0.DependencyControl.helpers.ffi-windows" + + 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 = ffiWin.haveKernel32 + haveWinInet, winInet = pcall ffi.load, "winInet" + + toWide = ffiWin.toWide + + 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: EventEmitter +class Download extends EventEmitter + @Status = DownloadStatus + @Event = Enum "DownloadEvent", { Progress: "progress", Finish: "finish" } + + ---Creates a single download in the Queued state. + ---@param url string + ---@param outfile string Full output path. + ---@param 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 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: EventEmitter +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? fun(downloader: Downloader) Overrides the transfer implementation (defaults to the platform backend). + ---@param options? { stallTimeout?: number, maxConnections?: integer } 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 sha1? string Expected SHA-1 hash; verified automatically on finish. + ---@return Download? download + ---@return string? 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). + ---@param onProgress? fun(downloader: Downloader, percent: number) Called with this downloader and the aggregate progress (0-100) on each Progress event, for the duration of this call only. + ---@return Downloader self for chaining + await: (onProgress) => + @on @@Event.Progress, onProgress if onProgress + @_runner @ + @off @@Event.Progress, onProgress if onProgress + @_emit @@Event.Finished + return @ + + ---@return number progress 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 connected 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..ca13068 --- /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 Key/value pairs, or a list of names whose value defaults to their position. + ---@param __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 defined + ---@return any value The value mapped to the key, or nil if undefined. + 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 A single value, or a list of values to look up. + ---@param join? string|boolean Separator string for joining multiple keys, true for ", ", or false to return a list (default false). + ---@return string|string[]|nil keys The matching key(s), or nil when a single value is undefined. + ---@return string? err Error message when a single value is undefined. + 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 argName? string Argument name to include in the error message. + ---@return boolean? valid True when the value is a member, nil otherwise. + ---@return string? err Validation error message when invalid. + 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..71a0969 --- /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 any The event value (a member of the subclass's `@Event` enum). + ---@param callback fun(self: EventEmitter, ...) Called with the emitter instance plus any event arguments. + ---@return EventEmitter 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 any The event value. + ---@param callback function The exact callback previously passed to on(). + ---@return EventEmitter 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/FileLock.moon b/modules/l0/DependencyControl/FileLock.moon new file mode 100644 index 0000000..31c951a --- /dev/null +++ b/modules/l0/DependencyControl/FileLock.moon @@ -0,0 +1,119 @@ +ffi = require "ffi" +FileOps = require "l0.DependencyControl.FileOps" + +local openImpl, tryLockImpl, unlockImpl, closeImpl, isAvailable + +msgs = { + noImplementation: "No file lock implementation is available on this platform/build configuration." +} + +if ffi.os == "Windows" + ffiWin = require "l0.DependencyControl.helpers.ffi-windows" + + -- LockFileEx on a one-byte range on Windows + pcall ffi.cdef, [[ + void* CreateFileW(const wchar_t* name, unsigned long access, unsigned long share, void* sec, unsigned long disposition, unsigned long flags, void* template); + int LockFileEx(void* hFile, unsigned long flags, unsigned long reserved, unsigned long countLow, unsigned long countHigh, void* overlapped); + int UnlockFileEx(void* hFile, unsigned long reserved, unsigned long countLow, unsigned long countHigh, void* overlapped); + ]] + -- mirrors the fields of OVERLAPPED; zeroed, it locks the byte range at offset 0 + pcall ffi.cdef, "typedef struct { uintptr_t Internal; uintptr_t InternalHigh; unsigned long Offset; unsigned long OffsetHigh; void* hEvent; } DepCtrlOverlapped;" + + kernel32, toWide = ffiWin.kernel32, ffiWin.toWide + isAvailable = ffiWin.haveKernel32 + + -- CreateFileW + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + GENERIC_READ_WRITE = bit.bor(GENERIC_READ, GENERIC_WRITE) + + FILE_SHARE_READ = 0x1 + FILE_SHARE_WRITE = 0x2 + FILE_SHARE_READ_WRITE = bit.bor(FILE_SHARE_READ, FILE_SHARE_WRITE) + + OPEN_ALWAYS = 4 -- open the file, creating it if it doesn't exist + FILE_ATTRIBUTE_NORMAL = 0x80 -- a file without any special attributes + INVALID_HANDLE = ffi.cast "void*", -1 + + -- LockFileEx + LOCKFILE_FAIL_IMMEDIATELY = 1 -- fail instead of waiting when the range is locked + LOCKFILE_EXCLUSIVE_LOCK = 2 -- request an exclusive lock instead of a shared one + LOCK_EXCLUSIVE_NONBLOCKING = bit.bor(LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY) + + openImpl = (path) -> + handle = kernel32.CreateFileW toWide(path), GENERIC_READ_WRITE, FILE_SHARE_READ_WRITE, + nil, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nil + return nil if handle == INVALID_HANDLE + return {handle: handle, overlapped: ffi.new "DepCtrlOverlapped"} + tryLockImpl = (h) -> 0 != kernel32.LockFileEx h.handle, LOCK_EXCLUSIVE_NONBLOCKING, 0, 1, 0, h.overlapped + unlockImpl = (h) -> kernel32.UnlockFileEx h.handle, 0, 1, 0, h.overlapped + closeImpl = (h) -> kernel32.CloseHandle h.handle + +else + ffiPosix = require "l0.DependencyControl.helpers.ffi-posix" + + -- flock(2) on POSIX (per-open-file-description, so two independent opens contend even within one process); + pcall ffi.cdef, [[ + int open(const char* path, int flags, int mode); + int close(int fd); + int flock(int fd, int operation); + ]] + isAvailable = true + + -- flock + LOCK_SH = 1 -- request a shared lock + LOCK_EX = 2 -- request an exclusive lock + LOCK_NB = 4 -- fail instead of waiting if the lock is held by another process + LOCK_UN = 8 -- remove an existing lock held by this process + + LOCK_EXCLUSIVE_NONBLOCKING = bit.bor(LOCK_EX, LOCK_NB) + + openImpl = (path) -> + fd = ffi.C.open path, bit.bor(ffiPosix.FileAccessMode.ReadWrite, ffiPosix.FileCreationFlags.Create), ffiPosix.getFileMode('rw', 'r', 'r') + return nil if fd < 0 + return {fd: fd} + tryLockImpl = (h) -> 0 == ffi.C.flock h.fd, LOCK_EXCLUSIVE_NONBLOCKING + unlockImpl = (h) -> ffi.C.flock h.fd, LOCK_UN + closeImpl = (h) -> ffi.C.close h.fd + +---A cross-process advisory lock on a file. +---Usable as a cross-process lock primitive. +---Automatically released when the instance is garbage collected or when the process exits. +---However, unlike a semaphore, it cannot be forcibly taken from a process that is alive but hung. +---@class FileLock +class FileLock + -- whether the OS file-lock FFI is isAvailable on this platform/build + @isAvailable = isAvailable + + ---Opens (creating if absent) the lock file and prepares it for locking. + ---@param path string Full path to the lock file. + new: (path) => + @isOpen = false + assert isAvailable, msgs.noImplementation + normalizedPath, errMsg = FileOps.validateFullPath path, true + assert normalizedPath, errMsg + + handle = openImpl normalizedPath + return unless handle + @_handle = handle + @path = normalizedPath + @isOpen = true + + -- close the handle when this object is garbage collected to release the lock in case it's still being held + handleRef = handle + canary = newproxy true + (getmetatable canary).__gc = -> pcall closeImpl, handleRef + @_canary = canary + + ---Attempts to acquire the lock without blocking. + ---@return boolean acquired True if acquired. + tryLock: => @isOpen and tryLockImpl(@_handle) or false + + ---Releases the lock. Only the current holder should call this. + ---@return boolean issued True if a release was issued. + unlock: => + return false unless @isOpen + unlockImpl @_handle + true + +return FileLock diff --git a/modules/l0/DependencyControl/FileOps.moon b/modules/l0/DependencyControl/FileOps.moon new file mode 100644 index 0000000..07785c0 --- /dev/null +++ b/modules/l0/DependencyControl/FileOps.moon @@ -0,0 +1,708 @@ +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.", + doesntExist: "No such file or directory: '%s'." + notDir: "Expected '%s' to be a directory but found a %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;]' + } + ---@alias FileOpsHashType "sha1" + + -- 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 it 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 dir + + ---Generates a unique temporary directory path that does not exist yet. + ---@return string tempDirPath Absolute path to a unique, not-yet-existing temporary directory. + 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 list of paths (each a string or an array of path segments). + ---@param recurse? boolean Recurse into directories. + ---@param reSchedule? boolean Reschedule failed removals for the next restart. + ---@return boolean? overallSuccess True if all succeeded, false if any were rescheduled, nil on hard failure. + ---@return table details Per-path result tables keyed by path. + ---@return string? firstErr The first error encountered. + 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 remove(). + ---@param configDir? string Directory holding the FileOps config (defaults to the configured dir). + ---@return boolean? success + ---@return string? 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 + ---@param clobber? boolean Overwrite an existing target file. + ---@return boolean success + ---@return string? 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 separators, or nil on error. + ---@return string? err + 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. + ---@param path string + ---@return fun(): string? iterator + pathSegments: (path) -> path\gmatch "[^/\\]+" + + ---Moves a file to a target path, optionally replacing existing targets. + ---@param source string + ---@param target string + ---@param overwrite? boolean Replace an existing target file. + ---@return boolean success + ---@return string? 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. + 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 clobber? boolean Overwrite the file if it already exists (default false). + ---@return boolean success True if the file was written successfully. + ---@return string? err + 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 hashType? FileOpsHashType The hash algorithm to use (default SHA1). + ---@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 hashType? FileOpsHashType The hash algorithm to use (default SHA1). + ---@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.doesntExist\format path if mode == false + return nil, msgs.rmdir.notDir\format path, mode 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? success 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 (discards the last segment when checking/creating the directory). + ---@param recurse? boolean Also create any missing parent directories (default false). + ---@return boolean? created True if created, false if it already existed, nil if an error occurred. + ---@return string dirPathOrError The existing/created directory path, or an error message. + 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 Attribute name to retrieve (e.g. "mode", "size", "modification"), or nil for the full attribute table. + ---@return table|string|number|boolean|nil attr The requested attribute(s), false if absent, or nil on error. + ---@return string fullPath The validated full path, or an error message if the path was invalid. + ---@return string? device The device component of the path. + ---@return string? dir The directory component of the path. + ---@return string? file The file name component of the path. + 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 If specified, the required type of the filesystem entry. + ---@return boolean? exists True if it exists and matches the expected type, false if not, nil on error. + ---@return string? 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 checkFileExt? boolean Require the path to have a file extension. + ---@param basePath? string|string[] Base path to resolve relative paths against; relative paths are rejected without it. + ---@return string|false|nil normalizedPath The normalized path, or false/nil on error. + ---@return string? deviceOrErr The device/root component on success, or an error message on failure. + ---@return string? dir The directory component (success only). + ---@return string? file The file name component (success only). + 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 segments) the namespaced path is created under. + ---@param namespace string + ---@param ext string File extension (including the dot). + ---@param nested? boolean Convert namespace dots to path separators (default true). + ---@return string? path + ---@return string? 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..dbeb030 --- /dev/null +++ b/modules/l0/DependencyControl/GitRepository.moon @@ -0,0 +1,29 @@ +---Interface to a local git repository for running git commands. +---@class GitRepository +class GitRepository + ---Creates an interface to the git repository rooted at `dir`. + ---@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? output + 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 suffix + 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..ecbee24 --- /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 a path to the schema file or a pre-parsed table. + ---@param 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? valid True/false on a completed validation, nil if it couldn't run. + ---@return string? 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..8473db3 --- /dev/null +++ b/modules/l0/DependencyControl/Lock.moon @@ -0,0 +1,350 @@ +constants = require "l0.DependencyControl.Constants" +NamedSemaphore = require "l0.DependencyControl.NamedSemaphore" +FileLock = require "l0.DependencyControl.FileLock" +Timer = require "l0.DependencyControl.Timer" +Logger = require "l0.DependencyControl.Logger" +Enum = require "l0.DependencyControl.Enum" +Crypto = require "l0.DependencyControl.Crypto" +FileOps = require "l0.DependencyControl.FileOps" +json = require "json" + +DEFAULT_LOCK_WAIT_INTERVAL = 250 +DEFAULT_EXPIRY_DURATION = 5 * 60 +DEFAULT_HOLDER_NAME = "unknown" + +-- default lower bound on remaining lease before renew refreshes, so a renewal still lands +-- ahead of expiry despite system latency/hangs. +RENEW_SAFETY_MARGIN_MS = 2000 + +-- separates namespace from resource when hashing them into a single name token +NAMESPACE_RESOURCE_SEPARATOR = "\31" + +---@alias LockScope +---| "process" # Only Lua states within this process contend. +---| "global" # Every process in the session contends (advisory file lock). + +---@class LockArgs +---@field namespace? string Logical namespace component of the locked resource (default ""). +---@field resource? string Resource component within the namespace (default ""). +---@field holderName? string Human-readable holder name recorded for diagnostics (default "unknown"). +---@field logger? Logger +---@field expiresAfter? number Lease duration in seconds before a holder is considered stale (default 300). +---@field scope? LockScope Scope selecting the primitive and reach of exclusion (default "process"). +---@field recordHolder? boolean Write a holder side file while held (default true). +---@field overrideExpiry? boolean Judge foreign holders against this instance's expiresAfter rather than their recorded lease (default false). + +---@class GuardArgs: LockArgs +---@field timeout? number Acquire timeout in milliseconds (default math.huge). +---@field lockWaitInterval? number Poll interval in milliseconds while waiting (default 250). + +---Cooperative, named lock with per-resource granularity. Each distinct +---(scope, namespace, resource) maps to its own OS lock, so unrelated resources lock +---independently. A lock is mutually exclusive across every Lock instance -- and, for +---Global scope, across every process -- that targets the same tuple. +--- +---Scope (see Lock.Scope) selects the primitive and reach of exclusion: +--- Process: a named semaphore whose name embeds the pid; only Lua states within this +--- process contend. +--- Global: an OS advisory file lock (FileLock) shared by every process in the session -- +--- use for resources shared between Aegisub instances (e.g. a config file). The +--- kernel releases it if the holder crashes, so it never stays stuck; it cannot, +--- however, be taken from a holder that is alive but hung. +--- +---While held, the holder's identity and lease are recorded in a per-resource side file for +---diagnostics. Long operations should call renew! periodically to extend the recorded lease +---so waiters don't mistake a busy holder for a crashed one. +---@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 by %s, retrying in %ims..." + staleHolder: "Lock on resource '%s.%s' is held by %s whose lease lapsed %ds ago; the holder may have crashed or stalled without releasing it." + 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." + unavailable: "OS lock unavailable for resource '%s.%s'; '%s' (%s) is proceeding with a process-local lock only (no cross-process exclusion)." + } + 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'." + } + renew: { + notHeld: "cannot renew a lock that is not currently held by this instance" + } + guard: { + notAcquired: "Could not acquire lock on resource '%s.%s' for holder '%s' (%s): lock state %s." + } + } + + @logger = Logger fileBaseName: "DependencyControl.Lock" + + @LockState = Enum "LockState", { + Unknown: -1 + Unavailable: 0 + Available: 1 + Held: 2 + }, @logger + LockState or= @LockState + + @Scope = Enum "LockScope", { + Process: "process" + Global: "global" + }, @logger + Scope = @Scope + + @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 + + ---Builds the OS lock primitive backing a lock: a named semaphore for Process scope, an + ---advisory file lock for Global scope. + ---@param scope LockScope + ---@param token string OS-safe semaphore name token (Process scope). + ---@param lockFile string Full path to the lock file (Global scope). + ---@return table? primitive A primitive exposing isOpen, tryLock and unlock, or nil on invalid scope. + ---@return string? err + @createPrimitive = (scope, token, lockFile) => + scopeIsValid, errMsg = Scope\validate scope, "scope" + return nil, errMsg unless scopeIsValid + + if scope == Scope.Global + FileLock lockFile + else + -- Process-scoped names embed our pid, so unlink them on close: a future process + -- that reuses this pid must not inherit a stuck name. + NamedSemaphore token, true + + -- Derives the OS-safe semaphore name token, holder-file path and Global lock-file path + -- for a tuple. + deriveNames = (scope, namespace, resource) -> + hash = Crypto.sha1 "#{namespace}#{NAMESPACE_RESOURCE_SEPARATOR}#{resource}" + token = scope == Scope.Global and "#{constants.DEPCTRL_SHORT_NAME}_global_#{hash}" or "#{constants.DEPCTRL_SHORT_NAME}_p#{NamedSemaphore.pid}_#{hash}" + holderFilePath = aegisub.decode_path "?temp/depctrl_lock_#{token}.json" + lockFilePath = aegisub.decode_path "?temp/depctrl_lock_#{token}.lock" + return token, holderFilePath, lockFilePath + + ---Creates a lock for the given resource. + ---@param args LockArgs + new: (args) => + {namespace: @namespace, resource: @resource, holderName: @holderName, logger: @logger, + expiresAfter: @expiresAfter, scope: @scope, recordHolder: @recordHolder, + overrideExpiry: @overrideExpiry} = args + + @scope or= Scope.Process + assert Scope\validate @scope, 'scope' + + @logger or= @@logger + @expiresAfter or= DEFAULT_EXPIRY_DURATION + @holderName or= DEFAULT_HOLDER_NAME + @namespace or= "" + @resource or= "" + @recordHolder = true if @recordHolder == nil + @instanceId = @@uuid! + + token, holderFilePath, lockFilePath = deriveNames @scope, @namespace, @resource + @_holderFilePath = holderFilePath + @_primitive = @@createPrimitive @scope, token, lockFilePath + + -- mutable held-state shared with the GC canary (avoids capturing self) + state = {held: false} + @_state = state + + -- release any still-held lock when this object is garbage collected. + holderName, instanceId, namespace, resource, logger = @holderName, @instanceId, @namespace, @resource, @logger + primitive, recordHolder = @_primitive, @recordHolder + canary = newproxy true + (getmetatable canary).__gc = -> + if state.held + pcall logger.warn, logger, msgs.new.lockNotReleased, holderName, instanceId, namespace, resource + pcall -> + primitive\unlock! + FileOps.remove holderFilePath if recordHolder + state.held = false + + meta = getmetatable @ + setmetatable @, { + __metatable: meta + __index: meta.__index + __canary: canary + } + + ---Reads the holder record written by the current lock holder, or nil if none is + ---present/parseable. Read lock-free, so the record may be stale or briefly absent. + ---@return table? record + _readHolder: => + return nil unless @recordHolder + data = FileOps.readFile @_holderFilePath + return nil unless data + ok, record = pcall json.decode, data + return ok and type(record) == "table" and record or nil + + -- Records this instance as the current holder in the side file, stamping the lease + -- (expiresAt = now + expiresAfter) so waiters honor the holder's own expiry. Keeps the + -- original @acquiredAt across renewals. Also tracks the lease end on the monotonic + -- clock for this instance's own renew decisions (os.time is shared across processes but + -- coarse and can jump; the monotonic clock is local but fine-grained and steady). No-op + -- when holder recording is disabled; best-effort, so failures don't affect the lock. + _writeHolder: => + return unless @recordHolder + @expiresAt = os.time! + @expiresAfter + @_leaseExpiresMono = Timer.getTime! + @expiresAfter + record = { + holderName: @holderName, instanceId: @instanceId, pid: NamedSemaphore.pid + scope: @scope, namespace: @namespace, resource: @resource + acquiredAt: @acquiredAt, expiresAt: @expiresAt + } + ok, data = pcall json.encode, record + FileOps.writeFile @_holderFilePath, data, true if ok + + -- Removes the holder side file. No-op when holder recording is disabled. + _clearHolder: => + FileOps.remove @_holderFilePath if @recordHolder + + -- Human-readable description of the current foreign holder for log messages, e.g. + -- "'ConfigHandler' (pid 1234)" or "another instance" when no record is available. + _describeHolder: (record) => + return "another instance" unless record + "'#{record.holderName or DEFAULT_HOLDER_NAME}' (pid #{record.pid or "?"})" + + -- Timestamp at which a foreign holder's lease lapses, or nil if it can't be determined. + -- Honors the holder's recorded expiresAt unless overrideExpiry is set, in which case + -- this instance's expiresAfter is applied to the holder's acquiredAt instead. + _holderDeadline: (record) => + return nil unless record + if @overrideExpiry and record.acquiredAt + return record.acquiredAt + @expiresAfter + return record.expiresAt or (record.acquiredAt and record.acquiredAt + @expiresAfter) + + ---Returns the holder currently believed to hold this lock, or nil if it appears free. + ---Reads the side file lock-free, so the result is advisory: a holder whose lease has + ---lapsed (likely crashed) is reported as free, and a brand-new holder may not be visible + ---yet. Reports this instance too when it holds the lock. Requires holder recording. + ---@return table? record The holder record (holderName, pid, namespace, resource, ...). + getActiveHolder: => + record = @_readHolder! + return nil unless record + deadline = @_holderDeadline record + return nil if deadline and os.time! > deadline + return record + + ---Returns the current lock state for this instance. + ---Returns Held if this instance holds the lock, Unknown otherwise (the OS lock + ---can't be queried for foreign holders without attempting to acquire it). + ---@return integer state A Lock.LockState value. + getState: => + return @@LockState.Held if @_state.held + @@LockState.Unknown + + ---Attempts to acquire the lock, waiting up to timeout milliseconds. + ---@param timeout? number Maximum time to wait in milliseconds (default math.huge). + ---@param lockWaitInterval? number Poll interval in milliseconds while waiting (default 250). + ---@return integer state A Lock.LockState value (Held on success, Unavailable on timeout). + ---@return number timePassed Milliseconds spent waiting. + lock: (timeout = math.huge, lockWaitInterval = DEFAULT_LOCK_WAIT_INTERVAL) => + -- Without a working OS primitive we can't coordinate across states/processes; + -- degrade to a process-local grant so DepCtrl keeps functioning, and warn once. + unless @_primitive.isOpen + unless @_state.held + @logger\warn msgs.lock.unavailable, @namespace, @resource, @holderName, @instanceId + @_state.held = true + @acquiredAt = os.time! + @_writeHolder! + return @@LockState.Held, 0 + + 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 @_primitive\tryLock! + @_state.held = true + @acquiredAt = os.time! + @_writeHolder! + @logger\trace msgs.lock.attained, @holderName, @instanceId, @namespace, @resource + return @@LockState.Held, timePassed + + -- held by someone else: surface who, and warn when the holder's lease has + -- lapsed (likely crashed or stalled). Informational only -- a Global file + -- lock self-heals on crash, and a live holder's lock is never force-stolen. + record = @_readHolder! + holderDesc = @_describeHolder record + deadline = @_holderDeadline record + if deadline and os.time! > deadline + @logger\warn msgs.lock.staleHolder, @namespace, @resource, holderDesc, os.time! - deadline + + @logger\trace msgs.lock.heldByOther, @namespace, @resource, holderDesc, 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 integer state A Lock.LockState value. + ---@return number timePassed Milliseconds spent waiting (always 0). + tryLock: => + return @lock 0 + + ---Releases the lock held by this instance. + ---@return boolean? released True on success, nil if the lock wasn't held by this instance. + ---@return integer|string statusOrErr A Lock.LockState value on success, or an error message when not held. + release: => + unless @_state.held + return nil, msgs.release.failed\format @namespace, @resource, @holderName, @instanceId, msgs.release.notHeld + @_primitive\unlock! + @_clearHolder! + @_state.held = false + @acquiredAt = nil + @_leaseExpiresMono = nil + @logger\trace msgs.release.released, @holderName, @instanceId, @namespace, @resource + return true, @@LockState.Available + + ---Refreshes the held lock's lease when it is close to expiring, re-stamping its recorded + ---expiry to @expiresAfter from now. This only affects the metadata on this Lock instance + ---and the side file, so waiters don't mistake a busy holder for a crashed one. The underlying + ---OS lock remains held until explicitly released. To avoid unnecessary writes, the side file + ---is only updated when the remaining lease is approaching expiry. + ---@param expiryThreshold? number Renew only if the remaining lease is <= this many milliseconds; -1 forces an unconditional refresh. Defaults to the larger of half the lease or the safety margin, capped at the full lease. + ---@return boolean? renewed True if refreshed, false if still fresh, nil if the lock isn't held. + ---@return string? err Set (with nil renewed) only when the lock isn't held. + renew: (expiryThreshold) => + return nil, msgs.renew.notHeld unless @_state.held + return false unless @recordHolder and @_leaseExpiresMono + validForMs = @expiresAfter * 1000 + threshold = expiryThreshold or math.min math.max(validForMs / 2, RENEW_SAFETY_MARGIN_MS), validForMs + unless threshold < 0 -- negative forces a refresh + remainingMs = (@_leaseExpiresMono - Timer.getTime!) * 1000 + return false if remainingMs > threshold + @_writeHolder! + return true + + ---Acquires a lock for the given args and runs the provided body function. + ---Releases the lock when the body completes or throws. The held Lock is passed to the body + ---so it can call renew on it if needed. + ---@param args GuardArgs Lock constructor args plus optional timeout/lockWaitInterval for the acquire. + ---@param body fun(lock: Lock): ... Called with the held Lock; its return values are passed through. + ---@return any ... The body's return values on success, or nil + err if the lock couldn't be acquired. + @guard = (args, body) => + lock = @ args + state = lock\lock args.timeout or math.huge, args.lockWaitInterval or DEFAULT_LOCK_WAIT_INTERVAL + unless state == @LockState.Held + return nil, msgs.guard.notAcquired\format lock.namespace, lock.resource, lock.holderName, lock.instanceId, state + results = table.pack pcall body, lock + lock\release! + error results[2], 0 unless results[1] + return unpack results, 2, results.n diff --git a/modules/DependencyControl/Logger.moon b/modules/l0/DependencyControl/Logger.moon similarity index 66% rename from modules/DependencyControl/Logger.moon rename to modules/l0/DependencyControl/Logger.moon index 17a5abf..93a09b9 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 level? number Severity level (default: the logger's defaultLevel). + ---@param msg? string|table Message, or a list of lines joined with newlines. + ---@param insertLineFeed? boolean Append a trailing newline (default true). + ---@param prefix? string Line prefix (default: the logger's prefix). + ---@param indent? number Indentation depth (default: the logger's indent). + ---@param ... any Format arguments substituted into msg. + ---@return boolean written False if msg was empty, otherwise true. 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 Value to test for truthiness. + ---@param ... any Error message and format arguments logged when cond is falsy. + ---@return any cond The condition and any trailing arguments, returned unchanged when truthy. 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 Value to test for nil. + ---@param ... any Error message and format arguments logged when cond is nil. + ---@return any cond The condition and any trailing arguments, returned unchanged when not nil. + 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 Value to dump; tables are rendered recursively. + ---@param ignore? any Table key to omit from the dump. + ---@param level? number Log level (default: the logger's defaultLevel). + ---@param maxDepth? number Maximum table depth to recurse into. + 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 Value to render; tables are rendered recursively. + ---@param ignore? any Table key to omit from the dump. + ---@param maxDepth? number Maximum table depth to recurse into. + ---@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 71% rename from modules/DependencyControl/ModuleLoader.moon rename to modules/l0/DependencyControl/ModuleLoader.moon index e1f6738..4c1a64e 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/l0/DependencyControl/ModuleLoader.moon @@ -1,172 +1,180 @@ --- 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" +Common = require "l0.DependencyControl.Common" + +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 != Common.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 != Common.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, ModuleProvider.fullTraceback, 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 addFeeds? string[] Extra feed URLs to search when fetching missing modules (default: this script's feed). + ---@param skip? table Module names to skip, keyed by name (default: this module itself). + ---@return boolean success + ---@return string err Combined error message (empty on success). + @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 not ModuleProvider.isDepCtrlVersionRecord record + record = @@ moduleName: .moduleName, version: record, recordType: Common.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 + + 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 Common\getAutomationDir Common.ScriptType.Module + 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[] Feature name(s) whose optional modules to check. + ---@return boolean available + ---@return string? err Error message listing missing modules. + @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 Common\getAutomationDir Common.ScriptType.Module + 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..a3380ab --- /dev/null +++ b/modules/l0/DependencyControl/ModuleProvider.moon @@ -0,0 +1,152 @@ +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. + +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 + +msgs = { + runInitializer: { + initializerError: "Module #{moduleName} initializer error: %s" + } +} + +-- debug.traceback truncates chunk names to LUA_IDSIZE (60 chars); using debug.getinfo +-- directly lets us read the full untruncated source path from info.source. +fullTraceback = (msg) -> + parts = {} + parts[#parts+1] = msg if msg + parts[#parts+1] = "stack traceback:" + pathPrefixes = {} + if type(aegisub) == "table" + for alias in *{"?user", "?data", "?temp"} + ok, resolved = pcall aegisub.decode_path, alias + pathPrefixes[#pathPrefixes+1] = {resolved, alias} if ok and resolved + i = 2 + while true + info = debug.getinfo i, "Sln" + break unless info + src = info.source + if src\sub(1, 1) == "@" + src = src\sub 2 + elseif src\sub(1, 1) == "=" + -- "=[C]" → "[C]", "=name" → "name" + src = src\sub 2 + for {prefix, alias} in *pathPrefixes + if src\sub(1, #prefix) == prefix + src = alias .. src\sub #prefix + 1 + break + if src == "[C]" + -- anonymous C frames carry no useful location; named ones don't need a line number + parts[#parts+1] = "\t[C] in #{info.name}()" if info.name + else + entry = "#{src}:#{info.currentline}" + entry ..= " in #{info.name}()" if info.name + parts[#parts+1] = "\t#{entry}" + i += 1 + table.concat parts, "\n" + +-- Returns true when value is a live DependencyControl Record instance. Uses class name and the +-- presence of checkVersion rather than class identity so the test passes across self-update +-- reloads and other classes accidentally named "DependencyControl". +isDepCtrlVersionRecord = (value) -> + type(value) == "table" and + value.__class and value.__class.__name == constants.DEPCTRL_NAME and + type(value.checkVersion) == "function" + +-- 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" + initializer = ref[DEPCTRL_MODULE_INIT_HOOK_NAME] + return false unless initializer + return false if isDepCtrlVersionRecord ref.version + + success, errMsg = xpcall initializer, fullTraceback, DependencyControl + return true if success + return nil, msgs.runInitializer.initializerError\format ref.moduleName, errMsg + +-- 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" + + initialized, errMsg = runInitializer mod, DependencyControl + error errMsg if initialized == nil + return mod + +-- 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 + +---Resolves provided module aliases to their provider module through a custom package searcher. +---@class ModuleProvider +class ModuleProvider + ---Returns true when value is a live DependencyControl Record instance, regardless of which class object created it. + ---@param value any + ---@return boolean isRecord + @isDepCtrlVersionRecord = isDepCtrlVersionRecord + + ---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 handed to the initializer. + ---@return boolean|nil ran True if the initializer ran, false if there was nothing to run, nil on initializer error. + ---@return string? err Error message when the initializer raised. + @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 registered 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 + + @fullTraceback = fullTraceback + + ---Gets the provider namespace registered for an alias module name. + ---@param alias string + ---@return string? providerName 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/l0/DependencyControl/NamedSemaphore.moon b/modules/l0/DependencyControl/NamedSemaphore.moon new file mode 100644 index 0000000..0ef5feb --- /dev/null +++ b/modules/l0/DependencyControl/NamedSemaphore.moon @@ -0,0 +1,124 @@ +ffi = require "ffi" + +local formatName, openImpl, isOpenImpl, tryLockImpl, lockImpl, unlockImpl, closeImpl +local pid, isAvailable + +msgs = { + noImplementation: "No named semaphore implementation is available on this platform/build configuration." +} + +if ffi.os == "Windows" + -- On Windows, the kernel object is ref-counted and destroyed once the last handle closes, + -- so it self-heals after a holder process exits + + ffiWin = require "l0.DependencyControl.helpers.ffi-windows" -- registers the shared CloseHandle cdef + + 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);" + + WAIT_OBJECT_0 = 0 + INFINITE = 0xFFFFFFFF + + okPid, p = pcall -> tonumber ffi.C.GetCurrentProcessId! + pid = okPid and p or 0 + isAvailable = true + + formatName = (token) -> token + openImpl = (name) -> ffi.C.CreateSemaphoreA nil, 1, 1, name + isOpenImpl = (handle) -> handle != nil + tryLockImpl = (handle) -> ffi.C.WaitForSingleObject(handle, 0) == WAIT_OBJECT_0 + lockImpl = (handle) -> ffi.C.WaitForSingleObject handle, INFINITE + unlockImpl = (handle) -> ffi.C.ReleaseSemaphore handle, 1, nil + closeImpl = (name, handle, unlink) -> ffiWin.kernel32.CloseHandle handle + +else + -- POSIX named semaphore. Unlike Windows, the name persists in the kernel namespace + -- until it is unlinked or reboot, so it does not self-heal after a holder process dies. + + ffiPosix = require "l0.DependencyControl.helpers.ffi-posix" + -- Aegisub runs per-user, so semaphores don't need to be shared with others. + SEMAPHORE_FILE_MODE = ffiPosix.getFileMode "rw" + BINARY_SEMAPHORE_INITIAL_VALUE = 1 + SEM_FAILED = ffi.cast "void *", -1 -- sem_open's failure sentinel ((void*)-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); + ]] + + okPid, p = pcall -> tonumber ffi.C.getpid! + pid = okPid and p or 0 + isAvailable = true + + -- POSIX names must start with a single '/' and contain no other slashes. + formatName = (token) -> "/#{token}" + openImpl = (name) -> ffi.C.sem_open name, ffiPosix.FileCreationFlags.Create, SEMAPHORE_FILE_MODE, BINARY_SEMAPHORE_INITIAL_VALUE + isOpenImpl = (handle) -> handle != nil and handle != SEM_FAILED + tryLockImpl = (handle) -> ffi.C.sem_trywait(handle) == 0 + lockImpl = (handle) -> ffi.C.sem_wait handle + unlockImpl = (handle) -> ffi.C.sem_post handle + closeImpl = (name, handle, unlink) -> + ffi.C.sem_close handle + -- Other process's already-open handles keep working after an unlink + -- but a subsequent *new* open with the same name would create a new + -- semaphore with a value entirely separate from the old one, resulting + -- in multiple handles thinking they hold the lock. + ffi.C.sem_unlink name if unlink + + +---A non-reentrant binary semaphore identified by a name. +---Usable as a per-process or cross-process lock primitive. +---@class NamedSemaphore +class NamedSemaphore + -- whether the OS semaphore FFI is isAvailable at all on this platform/build + @isAvailable = isAvailable + + -- this process's id, exposed so callers can build process-scoped names and holder records + @pid = pid + + ---Gets a handle to the named semaphore for the given token, creating it if it doesn't exist. + ---@param token string A name token restricted to [A-Za-z0-9_]. + ---@param unlinkOnClose? boolean POSIX-only: remove the OS name when this instance is garbage-collected. + ---Use true for process-private names so a reused PID can't inherit a stale semaphore. + ---Use false for cross-process usage to prevent an exiting process from removing a name others still hold. + ---No effect on Windows, where names are cleaned up automatically when the last handle closes. + new: (token, unlinkOnClose = false) => + assert isAvailable, msgs.noImplementation + + @name = formatName token + @handle = openImpl @name + @isOpen = isOpenImpl @handle + return unless @isOpen + + -- close the OS handle when this object is garbage-collected. + name, handle, unlink = @name, @handle, unlinkOnClose + canary = newproxy true + (getmetatable canary).__gc = -> pcall closeImpl, name, handle, unlink + @_canary = canary + + ---Attempts to acquire without blocking. + ---@return boolean acquired True if the semaphore was acquired. + tryLock: => @isOpen and tryLockImpl(@handle) or false + + ---Blocks until the semaphore is acquired. + ---@return boolean issued True if a wait was issued (false only when unavailable). + lock: => + return false unless @isOpen + lockImpl @handle + true + + ---Releases one unit of the semaphore. Safe to call only by the current holder. + ---@return boolean issued True if a release was issued. + unlock: => + return false unless @isOpen + unlockImpl @handle + true + +return NamedSemaphore diff --git a/modules/l0/DependencyControl/Record.moon b/modules/l0/DependencyControl/Record.moon new file mode 100644 index 0000000..4d64341 --- /dev/null +++ b/modules/l0/DependencyControl/Record.moon @@ -0,0 +1,491 @@ +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 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 + + +---Constructor arguments for a [Record](lua://Record). All fields are optional; unset fields are +---filled from script_* globals (for automation scripts) or sensible defaults. +---@class RecordArgs +---@field [1]? table[] Required module specs, passed positionally. +---@field moduleName? string Module namespace; its presence marks this record as a module rather than an automation script. +---@field name? string Display name (defaults to the script/module name). +---@field description? string Description (defaults to script_description). +---@field author? string Author (defaults to script_author). +---@field version? number|string Semantic version (defaults to script_version). +---@field namespace? string Unique namespace (defaults to script_namespace). +---@field url? string Project or homepage URL. +---@field feed? string Update feed URL. +---@field configFile? string Config file name (defaults to ".json"). +---@field virtual? boolean Mark as a not-yet-installed placeholder record. +---@field recordType? integer A Common.RecordType value (default Managed). +---@field requiredModules? table[] Required module specs (alternative to the positional list). +---@field provides? string[] Module alias names this module satisfies for `require`. +---@field readGlobalScriptVars? boolean Read script_* globals for unset fields (default true). +---@field saveRecordToConfig? boolean Persist this record to the config file (default true). + +---DependencyControl record representing one managed or unmanaged script/module. +---@class Record: DependencyControlCommon +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: 50, + 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? record + @getRegisteredRecord = (namespace) => + record = recordsByNamespace[namespace] + record unless record and record.virtual + + ---Returns all currently registered live records keyed by namespace. + ---Includes virtual (not-yet-installed) placeholders. + ---@return table records + @getAllRegisteredRecords = => {ns, record for ns, record in pairs recordsByNamespace} + + 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 RecordArgs + 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 config + @loadConfig = => + if @config + @config\load! + else @config = ConfigView\get @depConf.file, {"config"}, @depConf.globalDefaults, @logger + + ---Loads this record's script/module configuration hive. + ---@param importRecord? boolean Overwrite this record's fields from the stored config (default false). + ---@return boolean shouldWriteConfig + 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. + 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 path + getConfigFileName: () => + return aegisub.decode_path "#{@@configDir}/#{@configFile}" + + ---Creates a ConfigView for this record's script-specific config file. + ---@param defaults? table Default values for the config. + ---@param section? string|string[] Config section path. + ---@param noLoad? boolean Skip loading the file immediately. + ---@return ConfigView + getConfigHandler: (defaults, section, noLoad) => + return ConfigView\get @getConfigFileName!, section, defaults, nil, noLoad + + ---Creates a logger preconfigured for this record. + ---@param args? table Logger options; missing fields are filled from this record's config. + ---@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 Version, or record, to compare against. + ---@param precision? SemverPrecision Precision to compare at (default "patch"). + ---@return boolean? satisfied + ---@return number|string|nil maskedOrError Masked comparison value on success, or an error message. + checkVersion: (value, precision = "patch") => + if type(value) == "table" and value.__class == @@ + value = value.version + return SemanticVersioning\check @version, value, precision + + + ---Retrieves managed submodules registered under this module namespace. + ---@return string[]? submodules Submodule namespaces, or nil for non-module records. + ---@return ConfigView? config The module config section handler. + 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 modules? table[] Module specs to load (default: this record's requiredModules). + ---@param addFeeds? string[] Extra feed URLs to search (default: this record's feed). + ---@return any ... The loaded module references, in order. + 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 ... any Forwarded to the test suite's import(). + registerTests: (...) => + return if @haveTestSuite == false or @testSuiteInitialized + + testSuiteIdentifier = UnitTestSuite\getTestSuiteRequireIdentifier @scriptType, @namespace + @haveTestSuite, testsOrErrorMsg = xpcall UnitTestSuite\require, ModuleProvider.fullTraceback, testSuiteIdentifier + if not @haveTestSuite + @testSuiteLoadError = testsOrErrorMsg unless testsOrErrorMsg\match "module '[^']+' not found" + 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}" + + -- Automation scripts run in their own isolated environment exactly once, so they register + -- their own test menu right here. Modules, by contrast, load in every script's environment; + -- registering from here would create duplicate menu entries, so their test menus are + -- registered centrally by the Toolbox (which loads each module exactly once). + @tests\registerMacros! if @testSuiteInitialized and @scriptType == @@ScriptType.Automation + + ---Finalizes module registration and swaps dummy module refs for real refs. + ---@param selfRef table The module's real exported table. + ---@param ... any Forwarded to registerTests(). + ---@return table selfRef + 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. + ---When the first argument is a function, name and description are taken from the script and the + ---remaining arguments shift left. + ---@param name? string|function Macro name, or the process function in the short signature. + ---@param description? string|function Macro description, or the validate function in the short signature. + ---@param process function Macro processing callback. + ---@param validate? function Aegisub validation callback. + ---@param isActive? function Aegisub is-active callback. + ---@param submenu? string|boolean Submenu name, or true to use the script name. + 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 macros? table[] Macro definitions, each an argument list for registerMacro. + ---@param submenuDefault? boolean Default submenu value applied when a macro omits it (default true). + 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? version The parsed integer version, or nil on error. + ---@return string? 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 valid + ---@return string? err + validateNamespace: => + return true if @virtual + return Common.validateNamespace @namespace + + ---Returns all candidate entry point paths for this record under a given base directory, + ---covering .moon and .lua extensions and init.* variants for modules. + ---@param baseDir string Absolute automation base directory. + ---@return string[] paths + getPossibleEntryPointPaths: (baseDir) => + isModule = @scriptType == Common.ScriptType.Module + subPath = isModule and @namespace\gsub("%.", "/") or @namespace + paths = {} + for ext in *{".moon", ".lua"} + if path = FileOps.validateFullPath "#{subPath}#{ext}", false, baseDir + paths[#paths+1] = path + if isModule + if path = FileOps.validateFullPath "#{subPath}/init#{ext}", false, baseDir + paths[#paths+1] = path + return paths + + ---Finds this record's primary entry point file, checking ?user then ?data automation directories. + ---@return string? path + ---@return boolean? isUserPath True when found under ?user, false when found under ?data, nil when not found. + getEntryPointPath: => + userDir = Common\getAutomationDir @scriptType, "?user" + for path in *@getPossibleEntryPointPaths userDir + return path, true if "file" == FileOps.attributes path, "mode" + + dataDir = Common\getAutomationDir @scriptType, "?data" + if dataDir and dataDir != userDir + for path in *@getPossibleEntryPointPaths dataDir + return path, false if "file" == FileOps.attributes path, "mode" + + -- TODO: what if a module is available in another package search path? + return nil, nil + + ---Uninstalls this managed record and removes matching files from automation paths. + ---@param removeConfig? boolean Also delete the record's config (default true). + ---@return boolean? success nil when the record can't be uninstalled (virtual/unmanaged). + ---@return table|string|nil result Per-file removal results, or an error message. + 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..08d3732 --- /dev/null +++ b/modules/l0/DependencyControl/ScriptTargetFilter.moon @@ -0,0 +1,80 @@ +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. +--- +--- ScriptTargetFilter!\include(Common.ScriptType.Module, "l0.DependencyControl") +--- ScriptTargetFilter!\includeAll Common.ScriptType.Module -- every module +--- ScriptTargetFilter!\includeAll! -- everything +--- ScriptTargetFilter {[Common.ScriptType.Module]: {include: {"l0.DependencyControl"}}} +---@class ScriptTargetFilter +class ScriptTargetFilter + @scriptTypeList = scriptTypeList + + ---@param spec? table Initial rules keyed by script type. + 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. + ---@private + ---@param scriptType integer + ---@return table rule + ruleFor: (scriptType) => + @rules[scriptType] or= {include: {}, exclude: {}} + @rules[scriptType] + + ---Includes a single namespace of the given script type. + ---@param scriptType integer + ---@param namespace string + ---@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. + ---@param scriptType? integer Script type to include all of; omit to include everything. + ---@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). + ---@param scriptType integer + ---@param namespace string + ---@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 integer[] scriptTypes + scriptTypes: => + [t for t in *@@scriptTypeList when @rules[t]] + + ---Tests whether a script of the given type and namespace should be processed. + ---@param scriptType integer + ---@param namespace string + ---@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..b87a2f3 --- /dev/null +++ b/modules/l0/DependencyControl/ScriptUpdateRecord.moon @@ -0,0 +1,151 @@ +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 of the underlying [FeedScriptData](lua://FeedScriptData) (name, changelog, etc.) +---are readable directly on the instance, and the active channel's +---[FeedChannelData](lua://FeedChannelData) fields (version, files, platforms, etc.) are +---exposed directly on the instance once a channel is selected. +---@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..e1c8d06 --- /dev/null +++ b/modules/l0/DependencyControl/SemanticVersioning.moon @@ -0,0 +1,109 @@ +SemanticVersioning = nil + +---@alias SemverPrecision +---| "major" +---| "minor" +---| "patch" + +---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 a string (e.g. "1.2.3"), a number, or nil. + ---@return number|false version The integer version, or false on error. + ---@return string? err 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 whether version `a` is greater than or equal to version `b`, up to the given precision. + ---@param a number|string The first version. + ---@param b number|string The second version. + ---@param precision? SemverPrecision Precision to compare at (default "patch"). + ---@return boolean? result True if a >= b, or nil on error. + ---@return number|string masked The masked value of b on success, or the error message on failure. + @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 + + ---Reports whether `version` is strictly higher than `reference`. Raises on invalid input. + ---@param version number|string + ---@param reference number|string + ---@return boolean + isHigher: (version, reference) -> + version, errMsg = SemanticVersioning\toNumber version + assert version, errMsg + referenceVersionNumber, errMsg = SemanticVersioning\toNumber reference + assert referenceVersionNumber, errMsg + + return version > referenceVersionNumber + + ---Reports whether `version` is strictly lower than `reference`. Raises on invalid input. + ---@param version number|string + ---@param reference number|string + ---@return boolean + 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..b918e89 --- /dev/null +++ b/modules/l0/DependencyControl/Stub.moon @@ -0,0 +1,146 @@ + +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 logger? Logger Logger to use; a default logger is used when nil. + ---@param unitTest? UnitTest Unit test instance used 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 table? table|string The table to stub into, or a module name (looked up in the module cache). + ---@param key? string The field name to replace; no table is modified when nil. + ---@param logger? Logger Logger to use; a default logger is used when nil. + ---@param unitTest? UnitTest Unit test instance to report assertion failures to; failures throw when nil. + 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. + ---@param impl function + ---@return Stub self + calls: (impl) => + @_replacement = impl + return @ + + ---Sets the stub to return fixed values on every call. + ---@param ... any Values to return from every call. + ---@return 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/Timer.moon b/modules/l0/DependencyControl/Timer.moon new file mode 100644 index 0000000..8513c7b --- /dev/null +++ b/modules/l0/DependencyControl/Timer.moon @@ -0,0 +1,89 @@ +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 = 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 + +---Timer with monotonic clock readings and millisecond sleep. +---Not affected by system clock changes. +---@class Timer +class Timer + ---Creates a new timer, running from the current time. + new: => + @accumulated = 0 + @start! + + ---Returns the seconds measured so far, excluding any intervals during which the + ---timer was stopped. + ---@return number seconds + timeElapsed: => @accumulated + (@running and getTime! - @startTime or 0) + + ---Resumes measurement from the current time. No-op if already running, so a prior + ---stop/start round trip never discards accumulated time. + ---@return Timer self for chaining + start: => + unless @running + @startTime = getTime! + @running = true + return @ + + ---Pauses measurement, folding the elapsed interval into the accumulated total. + ---No-op if already stopped. + ---@return Timer self for chaining + stop: => + if @running + @accumulated += getTime! - @startTime + @running = false + return @ + + ---Clears the accumulated time and restarts measuring from the current time, + ---preserving the running/stopped state. + ---@return Timer self for chaining + reset: => + @accumulated = 0 + @startTime = getTime! + return @ + + --- Sleeps for the given number of milliseconds. + ---@param ms number + sleep: sleep + + @sleep = sleep + + ---Returns the current value of the process's monotonic clock, in seconds, at + ---sub-second resolution. Only differences between readings are meaningful. + ---@return number seconds + @getTime = getTime + +return Timer diff --git a/modules/l0/DependencyControl/UnitTestSuite.moon b/modules/l0/DependencyControl/UnitTestSuite.moon new file mode 100644 index 0000000..049c401 --- /dev/null +++ b/modules/l0/DependencyControl/UnitTestSuite.moon @@ -0,0 +1,916 @@ + +Logger = require "l0.DependencyControl.Logger" +Common = require "l0.DependencyControl.Common" +Stub = require "l0.DependencyControl.Stub" +DependencyControl = nil + +package.path ..= "#{package.path\sub(-1) == ";" and "" or ";"}#{aegisub.decode_path "?user/automation/tests"}/?.lua;" + +---A class for all single unit tests. +---Provides useful assertion and logging methods for a user-specified test function. +---@class UnitTest +class UnitTest + @msgs = { + run: { + setup: "Performing setup... " + teardown: "Performing teardown... " + test: "Running test '%s'... " + ok: "OK." + failed: "FAILED!" + reason: "Reason: %s" + } + new: { + badTestName: "Test name must be of type %s, got a %s." + } + + assert: { + true: "Expected true, actual value was %s." + false: "Expected false, actual value was %s." + nil: "Expected nil, actual value was %s." + notNil: "Got nil when a value was expected." + truthy: "Expected a truthy value, actual value was falsy (%s)." + falsy: "Expected a falsy value, actual value was truthy (%s)." + type: "Expected a value of type %s, actual value was of type %s." + sameType: "Type of expected value (%s) didn't match type of actual value (%s)." + inRange: "Expected value to be in range [%d .. %d], actual value %d was %s %d." + almostEquals: "Expected value to be almost equal %d ± %d, actual value was %d." + notAlmostEquals: "Expected numerical value to not be close to %d ± %d, actual value was %d." + checkArgTypes: "Expected argument #%d (%s) to be of type %s, got a %s." + zero: "Expected 0, actual value was a %s." + notZero: "Got a 0 when a number other than 0 was expected." + compare: "Expected value to be a number %s %d, actual value was %d." + integer: "Expected numerical value to be an integer, actual value was %d." + positiveNegative: "Expected a %s number (0 %s), actual value was %d." + equals: "Actual value didn't match expected value.\n%s actual: %s\n%s expected: %s" + notEquals: "Actual value equals expected value when it wasn't supposed to:\n%s actual: %s" + is: "Expected %s, actual value was %s." + isNot: "Actual value %s was identical to the expected value when it wasn't supposed to." + itemsEqual: "Actual item values of table weren't %s to the expected values (checked %s):\n Actual: %s\nExpected: %s" + itemsEqualNumericKeys: "only continuous numerical keys" + itemsEqualAllKeys: "all keys" + 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 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. + ---Instead of calling this constructor you'd usually provide test data + ---in a table structure to UnitTestSuite() as an argument. + ---@param name string A descriptive title for the test. + ---@param f? fun(test: UnitTest, ...) The function containing the test code. + ---@param testClass UnitTestClass The test class this test belongs to. + ---@see UnitTestSuite.new + new: (@name, @f = -> , @testClass) => + @logger = @testClass.logger + error type(@logger) unless type(@logger) == "table" + @logger\assert type(@name) == "string", @@msgs.new.badTestName, type @name + + ---Runs the unit test function. + ---In addition to the UnitTest object itself, it also passes + ---the specified arguments into the function. + ---@param ... any Optional modules or other data the test function needs. + ---@return boolean success + ---@return string? errMsg 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 + + ---Formats and writes a "running test x" message to the log. + ---@private + logStart: => + @logger\logEx nil, @@msgs.run.test, false, nil, nil, @name + + ---Formats and writes the test result to the log. + ---In case of failure the message contains details about either the test assertion that failed + ---or a stack trace if the test ran into a different exception. + ---@private + ---@param errMsg? string The error message to log; defaults to the error from this test's last run. + logResult: (errMsg = @errMsg) => + if @success + @logger\logEx nil, @@msgs.run.ok, nil, nil, 0 + else + if @assertFailed + -- scrub useless stack trace from asserts provided by this module + errMsg = errMsg\gsub "%[%w+ \".-\"%]:%d+:", "" + errMsg = errMsg\gsub "stack traceback:.*", "" + @errMsg = errMsg + @logger\logEx nil, @@msgs.run.failed, nil, nil, 0 + @logger.indent += 1 + @logger\log @@msgs.run.reason, @errMsg + @logger.indent -= 1 + + ---Formats a message with a specified predefined template. + ---Currently only supports the "type" template. + ---@private + ---@param tmpl string The name of the template to use. + ---@param ... any Arguments required for formatting the message. + ---@return string + format: (tmpl, ...) => + inArgs = table.pack ... + outArgs = switch tmpl + when "type" then {tostring(inArgs[1]), type(inArgs[1])} + + @@msgs.formatTemplate[tmpl]\format unpack outArgs + + + -- static helper functions + + ---Compares equality of two specified arguments. + ---Requirements for two values to be considered equal: + ---[1] their types match + ---[2] their metatables are equal + ---[3] strings and numbers are compared by value; + --- functions and cdata are compared by reference; + --- tables must have equal values at identical indexes and are compared recursively + --- (i.e. two table copies of `{"a", {"b"}}` are considered equal) + ---@param a any The first value. + ---@param b any The second value. + ---@param aType? string If already known, the type of the first value (small performance benefit). + ---@param bType? string The type of the second value. + ---@return boolean equal True if a and b are equal, otherwise false. + equals: Common.equals + + ---Compares equality of two specified tables, ignoring table keys. + ---Works much like UnitTest:equals, but doesn't require table keys to be equal between a and b: + ---two tables are equal if an equal value is found in b for every value in a and vice versa. + ---By default this only looks at numerical indexes, as this kind of comparison rarely makes + ---sense for hash tables. + ---@param a table The first table. + ---@param b table The second table. + ---@param onlyNumKeys? boolean Disable to also compare items with non-numerical keys, at a performance cost (default true). + ---@param ignoreExtraAItems? boolean Make the comparison one-sided, ignoring items present in a but not in b (default false). + ---@param requireIdenticalItems? boolean Require table items to be identical (compared by reference) rather than equal (default false). + ---@return boolean equal + 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. + ---@param tbl table|string The table (or module name) containing the value to replace. + ---@param key string The field name to stub. + ---@return 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. + ---@param tbl table|string The table (or module name) containing the value to wrap. + ---@param key string The field name to spy on. + ---@return 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. + ---@private + ---@param condition any A falsy value causes the assertion to fail. + ---@param ... any Error message (may contain format templates), followed by its format arguments. + assert: (condition, ...) => + args = table.pack ... + msg = table.remove args, 1 + unless condition + @assertFailed = true + @logger\logEx 1, msg, nil, nil, 0, unpack args + + + -- type assertions + + ---Fails the assertion if the specified value didn't have the expected type. + ---@param val any The value to be type-checked. + ---@param expected string The expected type. + assertType: (val, expected) => + @checkArgTypes val: {val, "_any"}, expected: {expected, "string"} + actual = type val + @assert actual == expected, @@msgs.assert.type, expected, actual + + ---Fails the assertion if the types of the actual and expected value didn't match. + ---@param actual any The actual value. + ---@param expected any The expected value. + assertSameType: (actual, expected) => + actualType, expectedType = type(actual), type expected + @assert actualType == expectedType, @@msgs.assert.sameType, expectedType, actualType + + ---Fails the assertion if the specified value isn't a boolean. + ---@param val any The value expected to be a boolean. + assertBoolean: (val) => @assertType val, "boolean" + ---Shorthand for assertBoolean. + ---@param val any The value expected to be a boolean. + assertBool: (val) => @assertType val, "boolean" + + ---Fails the assertion if the specified value isn't a function. + ---@param val any The value expected to be a function. + assertFunction: (val) => @assertType val, "function" + + ---Fails the assertion if the specified value isn't a number. + ---@param val any The value expected to be a number. + assertNumber: (val) => @assertType val, "number" + + ---Fails the assertion if the specified value isn't a string. + ---@param val any The value expected to be a string. + assertString: (val) => @assertType val, "string" + + ---Fails the assertion if the specified value isn't a table. + ---@param val any The value expected to be a table. + assertTable: (val) => @assertType val, "table" + + ---Helper method to type-check arguments as a prerequisite to other asserts. + ---@private + ---@param args table Argument {value, expectedType} pairs keyed by argument name. + checkArgTypes: (args) => + i = 1 + for name, types in pairs args + 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 + + + -- boolean asserts + + ---Fails the assertion if the specified value isn't the boolean `true`. + ---@param val any The value expected to be `true`. + assertTrue: (val) => + @assert val == true, @@msgs.assert.true, @format "type", val + + ---Fails the assertion if the specified value doesn't evaluate to boolean `true`. + ---In Lua this is only ever the case for `nil` and boolean `false`. + ---@param val any The value expected to be truthy. + assertTruthy: (val) => + @assert val, @@msgs.assert.truthy, @format "type", val + + ---Fails the assertion if the specified value isn't the boolean `false`. + ---@param val any The value expected to be `false`. + assertFalse: (val) => + @assert val == false, @@msgs.assert.false, @format "type", val + + ---Fails the assertion if the specified value doesn't evaluate to boolean `false`. + ---In Lua `nil` is the only other value that evaluates to `false`. + ---@param val any The value expected to be falsy. + assertFalsy: (val) => + @assert not val, @@msgs.assert.falsy, @format "type", val + + ---Fails the assertion if the specified value is not `nil`. + ---@param val any The value expected to be `nil`. + assertNil: (val) => + @assert val == nil, @@msgs.assert.nil, @format "type", val + + ---Fails the assertion if the specified value is `nil`. + ---@param val any The value expected to not be `nil`. + assertNotNil: (val) => + @assert val != nil, @@msgs.assert.notNil, @format "type", val + + + -- numerical asserts + + ---Fails the assertion if a number is out of the specified range. + ---@param actual number The number expected to be in range. + ---@param min? number The minimum (inclusive) value. + ---@param max? number The maximum (inclusive) value. + assertInRange: (actual, min = -math.huge, max = math.huge) => + @checkArgTypes actual: {actual, "number"}, min: {min, "number"}, max: {max, "number"} + @assert actual >= min, @@msgs.assert.inRange, min, max, actual, "<", min + @assert actual <= max, @@msgs.assert.inRange, min, max, actual, ">", max + + ---Fails the assertion if a number is not lower than the specified value. + ---@param actual number The number to compare. + ---@param limit number The lower limit (exclusive). + assertLessThan: (actual, limit) => + @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} + @assert actual < limit, @@msgs.assert.compare, "<", limit, actual + + ---Fails the assertion if a number is not lower than or equal to the specified value. + ---@param actual number The number to compare. + ---@param limit number The lower limit (inclusive). + assertLessThanOrEquals: (actual, limit) => + @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} + @assert actual <= limit, @@msgs.assert.compare, "<=", limit, actual + + ---Fails the assertion if a number is not greater than the specified value. + ---@param actual number The number to compare. + ---@param limit number The upper limit (exclusive). + assertGreaterThan: (actual, limit) => + @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} + @assert actual > limit, @@msgs.assert.compare, ">", limit, actual + + ---Fails the assertion if a number is not greater than or equal to the specified value. + ---@param actual number The number to compare. + ---@param limit number The upper limit (inclusive). + assertGreaterThanOrEquals: (actual, limit) => + @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} + @assert actual >= limit, @@msgs.assert.compare, ">=", limit, actual + + ---Fails the assertion if a number is not within an expected value ± a specified margin. + ---@param actual number The actual value. + ---@param expected number The expected value. + ---@param margin? number The maximum (inclusive) acceptable margin of error (default 1e-8). + assertAlmostEquals: (actual, expected, margin = 1e-8) => + @checkArgTypes actual: {actual, "number"}, min: {expected, "number"}, max: {margin, "number"} + + margin = math.abs margin + @assert math.abs(actual-expected) <= margin, @@msgs.assert.almostEquals, + expected, margin, actual + + ---Fails the assertion if a number differs from another value by at most a specified margin. + ---Inverse of assertAlmostEquals. + ---@param actual number The actual value. + ---@param value number The value being compared against. + ---@param margin? number The maximum (inclusive) margin of error for the numbers to be considered equal (default 1e-8). + assertNotAlmostEquals: (actual, value, margin = 1e-8) => + @checkArgTypes actual: {actual, "number"}, value: {value, "number"}, max: {margin, "number"} + + margin = math.abs margin + @assert math.abs(actual-value) > margin, @@msgs.assert.almostEquals, value, margin, actual + + ---Fails the assertion if a number is not equal to 0 (zero). + ---@param actual number The value. + assertZero: (actual) => + @checkArgTypes actual: {actual, "number"} + @assert actual == 0, @@msgs.assert.zero, actual + + ---Fails the assertion if a number is equal to 0 (zero). + ---Inverse of assertZero. + ---@param actual number The value. + assertNotZero: (actual) => + @checkArgTypes actual: {actual, "number"} + @assert actual != 0, @@msgs.assert.notZero + + ---Fails the assertion if a specified number has a fractional component. + ---All numbers in Lua share a common data type, which is usually a double, + ---which is the reason this is not a type check. + ---@param actual number The value. + assertInteger: (actual) => + @checkArgTypes actual: {actual, "number"} + @assert math.floor(actual) == actual, @@msgs.assert.integer, actual + + ---Fails the assertion if a specified number is less than or equal to 0. + ---@param actual number The value. + ---@param includeZero? boolean Consider 0 to be positive (default false). + assertPositive: (actual, includeZero = false) => + @checkArgTypes actual: {actual, "number"}, includeZero: {includeZero, "boolean"} + res = includeZero and actual >= 0 or actual > 0 + @assert res, @@msgs.assert.positiveNegative, "positive", + includeZero and "included" or "excluded" + + ---Fails the assertion if a specified number is greater than or equal to 0. + ---@param actual number The value. + ---@param includeZero? boolean Consider 0 to be negative (default false). + assertNegative: (actual, includeZero = false) => + @checkArgTypes actual: {actual, "number"}, includeZero: {includeZero, "boolean"} + res = includeZero and actual <= 0 or actual < 0 + @assert res, @@msgs.assert.positiveNegative, "positive", + includeZero and "included" or "excluded" + + + -- generic asserts + + ---Fails the assertion if the actual value is not *equal* to the expected value. + ---On the requirements for equality see UnitTest:equals. + ---@param actual any The actual value. + ---@param expected any The expected value. + assertEquals: (actual, expected) => + @assert self.equals(actual, expected), @@msgs.assert.equals, type(actual), + @logger\dumpToString(actual), type(expected), @logger\dumpToString expected + + ---Fails the assertion if the actual value is *equal* to the expected value. + ---Inverse of assertEquals. + ---@param actual any The actual value. + ---@param expected any The expected value. + assertNotEquals: (actual, expected) => + @assert not self.equals(actual, expected), @@msgs.assert.notEquals, + type(actual), @logger\dumpToString expected + + ---Fails the assertion if the actual value is not *identical* to the expected value. + ---Uses the `==` operator, so in contrast to assertEquals, this compares tables by reference. + ---@param actual any The actual value. + ---@param expected any The expected value. + assertIs: (actual, expected) => + @assert actual == expected, @@msgs.assert.is, @format("type", expected), + @format "type", actual + + ---Fails the assertion if the actual value is *identical* to the expected value. + ---Inverse of assertIs. + ---@param actual any The actual value. + ---@param expected any The expected value. + assertIsNot: (actual, expected) => + @assert actual != expected, @@msgs.assert.isNot, @format "type", expected + + + -- table asserts + + ---Fails the assertion if the items of one table aren't *equal* to the items of another. + ---Unlike assertEquals this ignores table keys, so e.g. two numerically-keyed tables with equal + ---items in a different order are still considered equal. By default only values at numerical + ---indexes are compared (see UnitTest:itemsEqual for details). + ---@param actual table The first table. + ---@param expected table The second table. + ---@param onlyNumKeys? boolean Disable to also compare items with non-numerical keys, at a performance cost (default true). + assertItemsEqual: (actual, expected, onlyNumKeys = true) => + @checkArgTypes { actual: {actual, "table"}, expected: {actual, "table"}, + onlyNumKeys: {onlyNumKeys, "boolean"} + } + + @assert self.itemsEqual(actual, expected, onlyNumKeys), + @@msgs.assert[onlyNumKeys and "itemsEqualNumericKeys" or "itemsEqualAllKeys"], + @logger\dumpToString(actual), @logger\dumpToString expected + + + ---Fails the assertion if the items of one table aren't *identical* to the items of another. + ---Like assertItemsEqual this ignores table keys, but compares table items by reference. + ---By default only values at numerical indexes are compared (see UnitTest:itemsEqual for details). + ---@param actual table The first table. + ---@param expected table The second table. + ---@param onlyNumKeys? boolean Disable to also compare items with non-numerical keys (default true). + assertItemsAre: (actual, expected, onlyNumKeys = true) => + @checkArgTypes { actual: {actual, "table"}, expected: {actual, "table"}, + onlyNumKeys: {onlyNumKeys, "boolean"} + } + + @assert self.itemsEqual(actual, expected, onlyNumKeys, nil, true), + @@msgs.assert[onlyNumKeys and "itemsEqualNumericKeys" or "itemsEqualAllKeys"], + @logger\dumpToString(actual), @logger\dumpToString expected + + ---Fails the assertion if the numerically-keyed items of a table aren't continuous. + ---The rationale is that when iterating a table with ipairs or retrieving its length with the + ---# operator, Lua may stop processing once the item at index n is nil, hiding subsequent values. + ---@param tbl table The table to be checked. + assertContinuous: (tbl) => + @checkArgTypes { tbl: {tbl, "table"} } + + realCnt, contCnt = 0, #tbl + for _, v in pairs tbl + if type(v) == "number" and math.floor(v) == v + realCnt += 1 + + @assert realCnt == contCnt, @@msgs.assert.continuous, contCnt+1, realCnt + + -- string asserts + + ---Fails the assertion if a string doesn't match the specified pattern. + ---Accepts a Lua string pattern or a compiled aegisub.re pattern object. + ---@param str string The input string. + ---@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. + ---@param str string The input string. + ---@param needle string The substring to be found. + ---@param caseSensitive? boolean Disable for locale-dependent case-insensitive comparison (default true). + ---@param init? number The first byte to start the search at (default 1). + assertContains: (str, needle, caseSensitive = true, init = 1) => + @checkArgTypes { str: {str, "string"}, needle: {needle, "string"}, + caseSensitive: {caseSensitive, "boolean"}, init: {init, "number"} + } + + _str, _needle = if caseSensitive + str\lower!, needle\lower! + else str, needle + @assert str\find(needle, init, true), str, needle, + caseSensitive and "sensitive" or "insensitive" + + -- function asserts + + + ---Fails the assertion if calling a function with the specified arguments doesn't make it throw an error. + ---@param func function The function to be called. + ---@param ... any Arguments to be passed into the function. + ---@return any error The error raised by the function. + assertError: (func, ...) => + @checkArgTypes { func: {func, "function"} } + + res = table.pack pcall func, ... + retCnt, success = res.n, table.remove res, 1 + res.n = nil + @assert success == false, @@msgs.assert.error, retCnt, @logger\dumpToString res + return res[1] + + ---Fails the assertion if a function call doesn't raise an error message matching the specified pattern. + ---Accepts a Lua string pattern or a compiled aegisub.re pattern object. + ---@param func function The function to be called. + ---@param params? table A table of arguments to be passed into the function (default {}). + ---@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 + 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. +---@class UnitTestSetup: UnitTest +class UnitTestSetup extends UnitTest + ---Runs the setup routine. + ---Only the UnitTestSetup object is passed into the function. + ---Values returned by the setup routine are stored to be passed into the test functions later. + ---@return boolean success + ---@return table|string retValsOrErr All returned values packed into a table on success, or the error message on failure. + 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] + + if @success + @retVals = res + return true, @retVals + + return false, @errMsg + +---A special case of the UnitTest class for a teardown routine. +---@class UnitTestTeardown: UnitTest +class UnitTestTeardown extends UnitTest + ---Formats and writes a "running test x" message to the log. + ---@private + logStart: => + @logger\logEx nil, @@msgs.run.teardown, false + + +---Holds a unit test class, i.e. a group of unit tests with common setup and teardown routines. +---@class UnitTestClass +class UnitTestClass + msgs = { + run: { + runningTests: "Running test class '%s' (%d tests)..." + setupFailed: "Setup for test class '%s' FAILED, skipping tests." + 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 tests and optional setup and teardown. + ---Instead of calling this constructor directly, prefer UnitTestSuite(), which takes a table of test + ---functions and creates test classes automatically. + ---@param name string A descriptive name for the test class. + ---@param args? table Test functions by name. Keys starting with "_" have special meaning and aren't added as regular tests: + --- * _setup: a UnitTestSetup routine + --- * _teardown: a UnitTestTeardown routine + --- * _order: alternative syntax to the order parameter + --- * _condition: a predicate `() -> boolean[, string reason]` evaluated before the class runs; a falsy result skips the whole class (its tests are marked skipped, with the optional reason). Use it to gate environment-dependent tests, e.g. `_condition: -> os.getenv("DEPCTRL_INTEGRATION") == "1"`. + ---@param order? string[] Test names in the desired execution order; only listed tests run when running the whole class. Unordered if omitted. + ---@param testSuite UnitTestSuite The suite this class belongs to. + new: (@name, args = {}, @order, @testSuite) => + @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] + + ---Runs all tests in the unit test class in the specified order. + ---@param abortOnFail? boolean Stop testing once a test fails (default false). + ---@param order? string[] Overrides the default test order. + ---@return boolean success + ---@return UnitTest[]|integer failed On failure, the failed tests (or -1 when setup 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} + for i, name in ipairs order + @logger\assert mappings[name], msgs.run.testNotFound, name + tests[i] = mappings[name] + testCnt, failedCnt = #tests, 0 + + @logger\log msgs.run.runningTests, @name, testCnt + @logger.indent += 1 + + success, res = @setup\run! + -- 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\warn msgs.run.abort, @name, i + 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 + + @logger\log msgs.run.testsFailed, @name, failedCnt, testCnt + 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. in CI environments). + ---@param leaf string The module name relative to the test root (e.g. "FileOps"). + ---@return any module 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: { + running: "Running %d test classes for %s... " + aborted: "Aborting after %d test classes... " + classesFailed: "FAILED %d of %d test classes." + success: "All tests completed successfully." + classNotFound: "Couldn't find requested test class '%s'." + } + registerMacros: { + allDesc: "Runs the whole test suite." + } + new: { + badClassesType: "Test classes must be passed in either as a table or an import function, got a %s" + } + import: { + noTableReturned: "The test import function must return a table of test classes, got a %s." + } + } + + @UnitTest = UnitTest + @UnitTestClass = UnitTestClass + @UnitTestSuiteControls = UnitTestSuiteControls + @Stub = Stub + + ---Returns 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 integer A Common.ScriptType value (module or automation script). + ---@param namespace string The namespaced identifier of the package under test (e.g. 'l0.Functional'). + ---@return string identifier 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 integer A Common.ScriptType value (module or automation script). + ---@param namespace string The namespaced identifier of the package under test (e.g. 'l0.Functional'). + ---@return string identifier + @getTestSuiteRequireIdentifier = (scriptType, namespace) => + DependencyControl or= require "l0.DependencyControl" + + switch type(DEPCTRL_UNIT_TEST_SUITE_REQUIRE_IDENTIFIER) + when "nil" then @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 suiteIdentifier string The require specifier of the test suite to load. Use getTestSuiteRequireIdentifier to obtain it for Aegisub environments. + ---@return any test 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 creates all test classes and tests automatically. + ---@param namespace string The namespace of the module or automation script to test. + ---@param classes table|fun(...): table The test classes by name, or a function that returns them. The function receives, depending on how tests are registered: + --- * self: the module being tested (skipped for automation scripts) + --- * dependencies: a numerically keyed table of all modules required by the tested script/module (in order) + --- * args: any additional arguments passed into DependencyControl:registerTests (required to test automation scripts and module internals). + ---Keys starting with "_" have special meaning and aren't added as regular tests (e.g. _order). + ---@param order? string[] Test class names in the desired execution order; only listed classes run when running the whole suite. Unordered if omitted. + new: (@namespace, classes, @order) => + @logger = Logger defaultLevel: 3, fileBaseName: @namespace, fileSubName: "UnitTests", toFile: true + @classes = {} + switch type classes + when "table" then @addClasses classes + when "function" then @importFunc = classes + else @logger\error msgs.new.badClassesType, type classes + + ---Constructs test classes and adds them to the suite. + ---Use this to add additional test classes to an existing UnitTestSuite object. + ---@param classes table UnitTestClass constructor tables by name. + addClasses: (classes) => + @classes[#@classes+1] = UnitTestClass(name, args, args._order, @) for name, args in pairs classes when "_" != name\sub 1,1 + if classes._order + @order or= {} + @order[#@order+1] = clsName for clsName in *classes._order + + ---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 and its declared dependencies). + ---@param ... any Dependencies or other arguments passed to the test suite's import function. + import: (...) => + return false unless @importFunc + controls = UnitTestSuiteControls @ + classes = (@importFunc) ..., controls + @logger\assert type(classes) == "table", msgs.import.noTableReturned, type classes + @addClasses classes + @importFunc = nil + + ---Registers macros for running all or specific test classes of this suite. + ---If the test script is placed in the appropriate directory (per the module/automation script namespace), + ---DependencyControl handles this automatically. + 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. + ---@param leafIdentifier string The module name relative to the test root (e.g. "FileOps"). + ---@return any module The loaded test module. + 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 abortOnFail? boolean Stop testing once a test fails (default false). + ---@param order? string[] Overrides the default test class order. + ---@return boolean success + ---@return UnitTest[]? failed The failed tests, or nil on success. + run: (abortOnFail, order = @order) => + classes, allFailed = @classes, {} + if order + classes, mappings = {}, {cls.name, cls for cls in *@classes} + for i, name in ipairs order + @logger\assert mappings[name], msgs.run.classNotFound, name + classes[i] = mappings[name] + + classCnt, failedCnt = #classes, 0 + @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 + failedCnt += 1 + allFailed[#allFailed+1] = test for test in *failed + if abortOnFail + @logger.indent -= 1 + @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 + + ---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. + ---@private + ---@return { name: string, cases: table[] }[] suites + 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. + ---@return table report 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. + ---@param path string Destination file path. + ---@return boolean? success True on success, nil on failure. + ---@return string? err An error message on failure. + 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..de8d1ae --- /dev/null +++ b/modules/l0/DependencyControl/UpdateFeed.moon @@ -0,0 +1,745 @@ +-- 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" +Downloader = require "l0.DependencyControl.Downloader" +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. +---@param localFileBasePath string The resolved local base path for this file (captured from the rolling template state). +attachLocalFilePath = (file, feedDirPath, localFileBasePath) -> + setmetatable file, __index: (self, key) -> + return unless key == "localFilePath" + name = rawget self, "name" + return unless localFileBasePath and name + path = FileOps.validateFullPath localFileBasePath .. 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: DependencyControlCommon +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: "./", + + -- keyBy/keyAt/keyDefault: the JSON value may be a keyed object (e.g. + -- {script:…, test:…}) instead of a plain string. If the object selected + -- by keyAt doesn't have an entry for keyBy, entry[keyDefault] is used as a fallback. + keyBy: "type", + keyAt: "files", + keyDefault: "script" + } + fileBaseUrl: { + key: "fileBaseUrl", + rolling: true, + keyBy: "type", + keyAt: "files", + keyDefault: "script" + } + } + 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 = {} + + ---@alias UpdateFeedExpansionMode + ---| "remote" # Expand `fileBaseUrl`/`url` to their download URLs. + ---| "local" # Additionally resolve `localFileBasePath`/`localFilePath` to on-disk paths. + + ---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 integer A Common.ScriptType value. + ---@param fileName string The file's feed name (e.g. ".moon", "/Common.moon"). + ---@param fileType? string "script" or "test" (default "script"). + ---@param rootDir? string The root directory for deployment. + ---@return string? path + ---@return string? err + @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 Feed URL (or nil when loading from a local file via fileName). + ---@param autoLoad? boolean Fetch/load the feed immediately (default true). + ---@param fileName? string Local feed file path. + ---@param config? table Feed config overrides. + ---@param 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 Destination path (defaults to a generated temp path). + ---@param expansionMode? UpdateFeedExpansionMode + ---@return table|boolean dataOrSuccess + ---@return string? err + fetch: (fileName, expansionMode) => + -- Initialize download infrastructure lazily on first fetch. + unless @downloader + @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"} + @downloader = Downloader! + @fileName = fileName if fileName + + dl, err = @downloader\addDownload @url, @fileName + unless dl + return false, msgs.errors.downloadAdd\format @url, @fileName, err + + @downloader\await! + 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 was either provided in the + ---constructor, or set to a temporary path when the feed is fetched. + ---@param expansionMode? UpdateFeedExpansionMode Expansion mode. Defaults to remote if the feed + ---was loaded from a URL; otherwise local, which resolves the rolling localFileBasePath template + ---variables and exposes the `localFilePath` property on file records for build tooling such as the bundler. + ---@return table|boolean dataOrSuccess The expanded feed data, or false on failure. + ---@return string? 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|nil feedData The expanded feed data, false on failure, or nil on a local-path error. + ---@return string? err 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 additionally resolves 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 + + -- Collapse a keyed rolling object to its plain string once it reaches an + -- object under the template's `keyAt` key (see template declaration). + with templates[name] + if .keyBy and upKey == .keyAt and type(rvars[depth][name]) == "table" + keyValue = obj[.keyBy] or .keyDefault + resolved = rvars[depth][name][keyValue] if keyValue + rvars[depth][name] = resolved if resolved + -- Only write back when the key is already present + obj[templates[name].key] = rvars[depth][name] if obj[templates[name].key] != nil + + -- file records (array entries under a `files` key) get a lazy localFilePath accessor + attachLocalFilePath obj, @feedDir, rvars[depth]["localFileBasePath"] 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 integer|boolean A Common.ScriptType value (true/false accepted for legacy module/automation). + ---@param config? table + ---@param autoChannel? boolean Select the default channel automatically. + ---@return ScriptUpdateRecord|boolean|nil record False when not found, nil on error. + ---@return string? 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 config? table + ---@param autoChannel? boolean Select the default channel automatically. + ---@return ScriptUpdateRecord|boolean|nil record False when not found, nil on error. + ---@return string? err + getMacro: (namespace, config, autoChannel) => + @getScript namespace, @@ScriptType.Automation, config, autoChannel + + ---Retrieves a module update record by namespace. + ---@param namespace string + ---@param config? table + ---@param autoChannel? boolean Select the default channel automatically. + ---@return ScriptUpdateRecord|boolean|nil record False when not found, nil on error. + ---@return string? 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? 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? name The resolved channel name, or nil if none matched. + ---@return string? 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? 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? valid Whether the feed is valid, or nil if validation couldn't be performed. + ---@return string? schemaVersion The feed format version validated against, if any. + ---@return string? message A success or error message. + 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 integer The script type of the package to refresh (Common.ScriptType.Automation or .Module). + ---@param packageNamespace string The package namespace. + ---@param rawChannel table The raw channel entry to update in place. + ---@return boolean? changed Whether anything was modified, or nil on error. + ---@return string? 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, ModuleProvider.fullTraceback, packageNamespace + ModuleProvider.runInitializer mod, DependencyControl if success + + record = DependencyControl\getRegisteredRecord 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 integer The package's script type (Common.ScriptType.Automation or .Module). + ---@param packageNamespace string The namespaced identifier of the package to update (e.g. "l0.Functional"). + ---@param channel? string The channel to update (default: the package's default channel). + ---@return { namespace: string, scriptType: integer, channel?: string, changed: boolean, errors: string[] } result + 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? { channel?: string, filter?: ScriptTargetFilter, schemaDir?: string|string[], outPath?: string|boolean } Options. `outPath` false performs a dry run; nil/true defaults to the loaded feed's source path. + ---@return { changed: integer, errored: integer, packages: table[] }|nil stats Per-run statistics, or nil on a fatal load/write error. + ---@return string? 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 filter? ScriptTargetFilter Restricts which packages are deployed (default: all). + ---@param clobber? boolean Overwrite existing destination files (default false). + ---@return number fileCount Number of files successfully copied. + ---@return number errCount Number of files that failed to copy (e.g. missing source file or copy error). + 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, true + 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 66% rename from modules/DependencyControl/Updater.moon rename to modules/l0/DependencyControl/Updater.moon index 888a105..db04d7a 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/l0/DependencyControl/Updater.moon @@ -1,14 +1,21 @@ lfs = require "lfs" -DownloadManager = require "DM.DownloadManager" -PreciseTimer = require "PT.PreciseTimer" - +constants = require "l0.DependencyControl.Constants" +Downloader = require "l0.DependencyControl.Downloader" +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" +Lock = require "l0.DependencyControl.Lock" ModuleLoader = require "l0.DependencyControl.ModuleLoader" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" DependencyControl = nil +UPDATER_LOCK_NAMESPACE = "#{constants.DEPCTRL_NAMESPACE}.Updater" +UPDATER_LOCK_RESOURCE_RUN = "run" + +---Shared updater error decoding and base behavior. +---@class UpdaterBase: DependencyControlCommon class UpdaterBase extends Common @logger = Logger fileBaseName: "DependencyControl.Updater" msgs = { @@ -21,20 +28,30 @@ 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" + [9]: "Skipped %s of %s '%s' because its entry point (%s) is in Aegisub's data automation directory. If it's managed by a system package manager, please update it through that instead." [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 integer A Common.ScriptType value. + ---@param isInstall boolean + ---@param detailMsg? string + ---@return string getUpdaterErrorMsg: (code, name, scriptType, isInstall, detailMsg) => if code <= -100 -- Generic downstream error @@ -46,8 +63,10 @@ class UpdaterBase extends Common @@terms.scriptType.singular[scriptType], name, detailMsg +---Mutable execution state for one install/update operation. +---@class UpdateTask: UpdaterBase class UpdateTask extends UpdaterBase - dlm = DownloadManager! + downloader = Downloader! msgs = { checkFeed: { downloadFailed: "Failed to download feed: %s" @@ -85,19 +104,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 targetVersionNumber? number Minimum version to install (default 0, i.e. any). + ---@param addFeeds? string[] + ---@param exhaustive? boolean Check all feeds and pick the highest version. + ---@param channel? string Update channel to use. + ---@param optional? boolean Treat this as an optional dependency. + ---@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 +136,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 boolean|nil ok True with an update available, false if none newer, nil on error. + ---@return table|string|nil recordOrErr The update record on success, or an error message on failure. + ---@return number? version The candidate's version number. checkFeed: (feedUrl) => -- get feed contents feed = UpdateFeed feedUrl, false, nil, @feedConfig, @logger @@ -121,8 +150,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 +177,13 @@ class UpdateTask extends UpdaterBase return true, updateRecord, version + ---Runs the full update/install flow for this task. + ---@param waitLock? boolean Wait for a concurrent update to finish instead of bailing. + ---@param exhaustive? boolean Check all feeds and pick the highest version. + ---@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 @@ -159,9 +194,14 @@ class UpdateTask extends UpdaterBase -- don't perform update of a script when another one is already running for the same script return logUpdateError -10 if @running + -- don't shadow scripts installed to the ?data automation dir with a ?user copy + entryPath, isUserPath = @record\getEntryPointPath! + if isUserPath == false + return logUpdateError -9, entryPath + -- 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 @@ -190,10 +230,10 @@ class UpdateTask extends UpdaterBase return logUpdateError -4 -- check internet connection - return logUpdateError -7 unless dlm\isInternetConnected! + return logUpdateError -7 unless downloader\isInternetConnected! -- get a lock on the updater - success, otherHost = @updater\getLock waitLock + success, otherHost = @updater\acquireLock waitLock return logUpdateError -5, otherHost unless success -- check feeds for update until we find and update or run out of feeds to check @@ -205,6 +245,7 @@ class UpdateTask extends UpdaterBase maxVer, updateRecord = 0 for i, feed in ipairs feeds + @updater\renewLock! @logger\log msgs.run.feedTrying, i, #feeds, feed res, rec, version = @checkFeed feed @@ -229,12 +270,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 +287,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 +330,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 @@ -294,18 +340,18 @@ class UpdateTask extends UpdaterBase scriptSubDir = @record.namespace scriptSubDir = scriptSubDir\gsub "%.","/" if @record.scriptType == @@ScriptType.Module - dlm\clear! + downloader\clear! for file in *update.files file.type or= "script" 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 @@ -315,22 +361,23 @@ class UpdateTask extends UpdaterBase unless type(file.sha1)=="string" and #file.sha1 == 40 and tonumber(file.sha1, 16) return finish -35, "#{prettyName} (#{tostring(file.sha1)\lower!})" - if dlm\checkFileSHA1 file.fullName, file.sha1 + if fileOps.verifyHash file.fullName, file.sha1 @logger\trace msgs.performUpdate.fileUnchanged, prettyName continue - dl, err = dlm\addDownload file.url, tmpName, file.sha1 + dl, err = downloader\addDownload file.url, tmpName, file.sha1 return finish -140, err unless dl dl.targetFile = file.fullName @logger\trace msgs.performUpdate.fileAddDownload, file.url, prettyName - dlm\waitForFinish (progress) -> - @logger\progress progress, msgs.performUpdate.filesDownloading, #dlm.downloads - return true + downloader\await (_, progress) -> + @updater\renewLock! + @logger\progress progress, msgs.performUpdate.filesDownloading, #downloader.downloads @logger\progress! - if #dlm.failedDownloads>0 - err = @logger\format ["#{dl.url}: #{dl.error}" for dl in *dlm.failedDownloads], 1 + failedDownloads = [dl for dl in *downloader.downloads when dl.status == Downloader.Download.Status.Failed] + if #failedDownloads>0 + err = @logger\format ["#{dl.url}: #{dl.error}" for dl in *failedDownloads], 1 return finish -245, err @@ -339,7 +386,7 @@ class UpdateTask extends UpdaterBase @logger\log msgs.performUpdate.movingFiles, @record.automationDir moveErrors = {} @logger.indent += 1 - for dl in *dlm.downloads + for dl in *downloader.downloads res, err = fileOps.move dl.outfile, dl.targetFile, true -- don't immediately error out if moving of a single file failed -- try to move as many files as possible and let the user handle the rest @@ -379,20 +426,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,15 +455,14 @@ 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: UpdaterBase class Updater extends UpdaterBase msgs = { - getLock: { - orphaned: "Ignoring orphaned in-progress update started by %s." - waitFinished: "Waited %d seconds." - abortWait: "Timeout reached after %d seconds." - waiting: "Waiting for update intiated by %s to finish..." + acquireLock: { + waiting: "Waiting for update initiated by %s to finish..." } require: { macroPassed: "%s is not a module." @@ -422,12 +470,27 @@ class Updater extends UpdaterBase } scheduleUpdate: { updaterDisabled: "Skipping update check for %s (Updater disabled)." + protectedInstall: "Skipping update check for %s '%s': its entry point (%s) is in Aegisub's data automation directory, managed outside of #{constants.DEPCTRL_NAME}." runningUpdate: "Running scheduled update for %s '%s'..." } } + ---Creates an updater coordinator for one host script context. + ---@param host? string Host script namespace (default script_namespace). + ---@param config ConfigView The global DependencyControl config view. + ---@param 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 A record, or a plain table to construct one from. + ---@param targetVersion? number|string Minimum version to install. + ---@param addFeeds? string[] + ---@param exhaustive? boolean Check all feeds and pick the highest version. + ---@param channel? string Update channel to use. + ---@param optional? boolean Treat this as an optional dependency. + ---@return UpdateTask? task + ---@return number? code + ---@return string? detail addTask: (record, targetVersion, addFeeds = {}, exhaustive, channel, optional) => DependencyControl or= require "l0.DependencyControl" if record.__class != DependencyControl @@ -435,18 +498,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 ... any Forwarded to addTask (targetVersion, addFeeds, ...). + ---@return any ref The loaded module reference, or nil on error. + ---@return number? code + ---@return string? 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 +532,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 status A status code, or the task's run result. scheduleUpdate: (record) => unless @config.c.updaterEnabled @logger\trace msgs.scheduleUpdate.updaterDisabled, record.name or record.namespace @@ -470,55 +546,74 @@ class Updater extends UpdaterBase -- the update interval has not yet been passed since the last update check if record.config.c.lastUpdateCheck and (record.config.c.lastUpdateCheck + @config.c.updateInterval > os.time!) - return false + return 0 record.config.c.lastUpdateCheck = os.time! record.config\write! + -- don't shadow scripts installed to the ?data automation dir with a ?user copy + entryPath, isUserPath = record\getEntryPointPath! + if isUserPath == false + @logger\trace msgs.scheduleUpdate.protectedInstall, @@terms.scriptType.singular[record.scriptType], + record.name or record.namespace, entryPath + return -9, entryPath + task = @addTask record -- no need to check for errors, because we've already accounted for those case @logger\trace msgs.scheduleUpdate.runningUpdate, @@terms.scriptType.singular[record.scriptType], record.name return task\run! - getLock: (doWait, waitTimeout = @config.c.updateWaitTimeout) => + -- Lazily builds this updater's handle to the shared, cross-process updater lock. + _getLockHandle: => + @lock or= Lock { + namespace: UPDATER_LOCK_NAMESPACE, resource: UPDATER_LOCK_RESOURCE_RUN + scope: Lock.Scope.Global, holderName: @host, logger: @logger + expiresAfter: @config.c.updateOrphanTimeout + } + return @lock + + ---Acquires the global updater lock shared across scripts and processes. + ---@param doWait boolean Wait for a concurrent update to finish instead of bailing out. + ---@param waitTimeout? number Seconds to wait when doWait is set. + ---@return boolean acquired + ---@return string? lockOwner The holder script's name when acquisition failed. + acquireLock: (doWait, waitTimeout = @config.c.updateWaitTimeout) => return true if @hasLock + lock = @_getLockHandle! - @config\load! - running, didWait = @config.c.updaterRunning - - if running and running.host != @host - if running.time + @config.c.updateOrphanTimeout < os.time! - @logger\log msgs.getLock.orphaned, running.host - elseif doWait - @logger\log msgs.getLock.waiting, running.host - timeout, didWait = waitTimeout, true - while running and timeout > 0 - PreciseTimer.sleep 1000 - timeout -= 1 - @config\load! - running = @config.c.updaterRunning - @logger\log timeout <= 0 and msgs.getLock.abortWait or msgs.getLock.waitFinished, - waitTimeout - timeout - - else return false, running.host - - -- register the running update in the config file to prevent collisions - -- with other scripts trying to update the same modules - -- TODO: store this flag in the db - - @config.c.updaterRunning = host: @host, time: os.time! - @config\write! - @hasLock = true + if doWait + holder = lock\getActiveHolder! + @logger\log msgs.acquireLock.waiting, holder.holderName if holder and holder.holderName != @host - -- reload important module version information from configuration - -- because another updater instance might have updated them in the meantime - if didWait - task\refreshRecord! for _,task in pairs @tasks[@@ScriptType.Module] + state, timePassed = lock\lock doWait and waitTimeout * 1000 or 0 + unless state == Lock.LockState.Held + holder = lock\getActiveHolder! + return false, holder and holder.holderName + @hasLock = true + -- if we actually had to wait, another updater may have updated modules in the meantime + if timePassed > 0 + task\refreshRecord! for _,task in pairs @tasks[@@ScriptType.Module] + return true + ---Renews the updater lock's lease if we currently hold it. + renewLock: => + @lock\renew! if @hasLock and @lock + + ---Releases the global updater lock. + ---@return boolean released releaseLock: => return false unless @hasLock @hasLock = false - @config.c.updaterRunning = false - @config\write! \ No newline at end of file + @lock\release! if @lock + return true + + ---Reports whether an update is currently running in any script or process. + ---@return boolean running + ---@return string? holderName The name of the script holding the updater lock. + @isRunning = => + holder = Lock({ + namespace: UPDATER_LOCK_NAMESPACE, resource: UPDATER_LOCK_RESOURCE_RUN, scope: Lock.Scope.Global + })\getActiveHolder! + return holder != nil, holder and holder.holderName diff --git a/modules/l0/DependencyControl/ZipArchiver.moon b/modules/l0/DependencyControl/ZipArchiver.moon new file mode 100644 index 0000000..d9dd788 --- /dev/null +++ b/modules/l0/DependencyControl/ZipArchiver.moon @@ -0,0 +1,164 @@ +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/helpers/ffi-posix.moon b/modules/l0/DependencyControl/helpers/ffi-posix.moon new file mode 100644 index 0000000..de32530 --- /dev/null +++ b/modules/l0/DependencyControl/helpers/ffi-posix.moon @@ -0,0 +1,50 @@ +-- POSIX open(2) flag/mode constants for FFI callers, with the per-OS numeric values +-- folded in. Linux values are the asm-generic ones used by x86/x86_64/arm/arm64 (the +-- platforms Aegisub ships on); a few historical arches (alpha, mips, parisc, sparc) differ +-- but are not supported. macOS (Darwin) values are taken from . + +ffi = require "ffi" + +isOSX = ffi.os == "OSX" + +filePermissionBits = {r: 4, w: 2, x: 1} + +{ + -- low two bits of the open(2) flags: the access mode (same on Linux and macOS) + FileAccessMode: { + Read: 0 -- O_RDONLY (read-only) + Write: 1 -- O_WRONLY (write-only) + ReadWrite: 2 -- O_RDWR (read-write) + } + + FileCreationFlags: { + Create: isOSX and 0x200 or 0x40 -- O_CREAT: create the file if it doesn't exist + Exclusive: isOSX and 0x800 or 0x80 -- O_EXCL: with Create, fail if the file already exists + Truncate: isOSX and 0x400 or 0x200 -- O_TRUNC: truncate the file to zero length + -- O_NOCTTY: don't let an opened terminal become the process's controlling terminal + NoControllingTerminal: isOSX and 0x20000 or 0x100 + Directory: isOSX and 0x100000 or 0x10000 -- O_DIRECTORY: fail if the path isn't a directory + NoFollow: isOSX and 0x100 or 0x20000 -- O_NOFOLLOW: fail if the final component is a symlink + -- O_CLOEXEC: set the close-on-exec flag so child processes don't inherit the fd + CloseOnExec: isOSX and 0x1000000 or 0x80000 + -- O_TMPFILE: create an unnamed temporary file (the Linux value already includes + -- O_DIRECTORY, as the kernel requires). Not available on macOS, where it is 0 (a + -- no-op) -- callers needing a temp file there must fall back to another mechanism. + TmpFile: isOSX and 0 or 0x410000 + } + + ---Builds the numeric file mode for the given symbolic permissions. + ---@param user? string Any combination of "r", "w" and "x" for the owner, or "" for none. + ---@param group? string Same, for the owner's group. + ---@param other? string Same, for all other users. + ---@return number mode The file mode, e.g. getFileMode("rwx", "r", "r") -> 0o744 (484). + getFileMode: (user = "", group = "", other = "") -> + mode = 0 + for perm in user\gmatch "." + mode += (filePermissionBits[perm] or 0) * 64 + for perm in group\gmatch "." + mode += (filePermissionBits[perm] or 0) * 8 + for perm in other\gmatch "." + mode += filePermissionBits[perm] or 0 + return mode +} diff --git a/modules/l0/DependencyControl/helpers/ffi-windows.moon b/modules/l0/DependencyControl/helpers/ffi-windows.moon new file mode 100644 index 0000000..bee262b --- /dev/null +++ b/modules/l0/DependencyControl/helpers/ffi-windows.moon @@ -0,0 +1,28 @@ +ffi = require "ffi" + +pcall ffi.cdef, [[ + int CloseHandle(void* hObject); + int MultiByteToWideChar(unsigned int cp, unsigned long flags, const char* str, int cbMulti, wchar_t* wide, int cchWide); +]] + +CP_UTF8 = 65001 -- code page identifier for UTF-8, passed to the *CP() conversion APIs + +haveKernel32, kernel32 = pcall ffi.load, "kernel32" + +{ + -- the loaded kernel32 library namespace, or nil if it couldn't be loaded + kernel32: haveKernel32 and kernel32 or nil + + -- whether kernel32 loaded successfully; gate any use of `kernel32`/`toWide` on this + haveKernel32: haveKernel32 + + ---Converts a UTF-8 string to a NUL-terminated wide-char (UTF-16) buffer for the *W Win32 APIs. + ---Requires kernel32 to have loaded (see `haveKernel32`); errors otherwise. + ---@param s string A UTF-8 encoded string. + ---@return ffi.cdata* buffer A wchar_t[] buffer holding the converted, NUL-terminated string. + 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 +} diff --git a/modules/l0/DependencyControl/shims/BadMutex.moon b/modules/l0/DependencyControl/shims/BadMutex.moon new file mode 100644 index 0000000..f749f82 --- /dev/null +++ b/modules/l0/DependencyControl/shims/BadMutex.moon @@ -0,0 +1,18 @@ +-- A stand-in for the process-scoped singleton BM.BadMutex, using our internal pure-FFI +-- NamedSemaphore implementation. + +constants = require "l0.DependencyControl.Constants" +NamedSemaphore = require "l0.DependencyControl.NamedSemaphore" + +-- A single fixed, process-private name +semaphore = NamedSemaphore "#{constants.DEPCTRL_SHORT_NAME}_BadMutex_p#{NamedSemaphore.pid}", true + +mutex = { + tryLock: -> semaphore\tryLock! + lock: -> semaphore\lock! + unlock: -> semaphore\unlock! + -- the BM.BadMutex version this is compatible with + version: "0.1.3" +} + +return mutex diff --git a/modules/l0/DependencyControl/shims/DownloadManager.moon b/modules/l0/DependencyControl/shims/DownloadManager.moon new file mode 100644 index 0000000..bd2a5de --- /dev/null +++ b/modules/l0/DependencyControl/shims/DownloadManager.moon @@ -0,0 +1,85 @@ +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 +---DependencyControl Downloader engine. +---@class DownloadManager +class DownloadManager + -- Matches the DM.DownloadManager dependency version declared in DependencyControl.moon + -- so DependencyControl accepts this implementation without a full managed record. + @version = "0.3.1" + + ---Creates a download manager. + ---@param 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 sha1? string Expected SHA-1 hash. + ---@param etag? string Accepted for API compatibility; ignored. + ---@return Download? download + ---@return string? err + addDownload: (url, outfile, sha1, etag) => + @downloader\addDownload url, outfile, sha1 + + ---Performs all queued downloads (DM.DownloadManager-compatible). + ---@param callback? fun(progress: number): any 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 + @downloader\await (_, percent) -> @downloader\cancel! unless callback percent + 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 progress Current aggregate progress (0-100). + progress: => @downloader\progress! + + cancel: => @downloader\cancel! + clear: => @downloader\clear! + + ---@return boolean connected Whether an internet connection appears to be available. + isInternetConnected: => @downloader\isInternetConnected! + + ---Computes the SHA-1 of a file's contents. + ---@param filename string + ---@return string? hexDigest + ---@return string? err + getFileSHA1: (filename) => FileOps.getHash filename, "sha1" + + ---Verifies a file against an expected SHA-1 hash. + ---@param filename string + ---@param expected string Expected SHA-1 hex digest. + ---@return boolean? match + ---@return string? err + checkFileSHA1: (filename, expected) => FileOps.verifyHash filename, expected, "sha1" + + ---Verifies a string against an expected SHA-1 hash. + ---@param str string + ---@param expected string Expected SHA-1 hex digest. + ---@return boolean? match + ---@return string? 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/shims/PreciseTimer.moon b/modules/l0/DependencyControl/shims/PreciseTimer.moon new file mode 100644 index 0000000..0349bfb --- /dev/null +++ b/modules/l0/DependencyControl/shims/PreciseTimer.moon @@ -0,0 +1,28 @@ +Timer = require "l0.DependencyControl.Timer" + +-- The native PT.PreciseTimer exposes the path of the loaded shared library. This pure-Lua +-- shim has no library, so expose this module's own source path as the closest equivalent. +selfPath = debug.getinfo(1, "S").source\gsub "^@", "" + +---A monotonic stopwatch replicating the native PT.PreciseTimer API on top of the +---DependencyControl Timer engine. +---@class PreciseTimer +class PreciseTimer + -- mirrors the native PT.PreciseTimer version this shim is API-compatible with + @version = 0x000106 + @version_string = "0.1.6" + @loadedLibraryPath = selfPath + + --- Starts a new timer, capturing the current time as its start point. + new: => + @timer = Timer! + + --- Returns the seconds elapsed since the timer was created. + ---@return number seconds + timeElapsed: => @timer\timeElapsed! + + --- Sleeps for the given number of milliseconds. + ---@param ms? number milliseconds to sleep (defaults to 100) + sleep: (ms = 100) -> Timer.sleep ms + +return PreciseTimer diff --git a/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon new file mode 100644 index 0000000..a5715d5 --- /dev/null +++ b/modules/l0/DependencyControl/test.moon @@ -0,0 +1,1891 @@ +constants = require "l0.DependencyControl.Constants" +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + +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" + Downloader = require "l0.DependencyControl.Downloader" + Crypto = require "l0.DependencyControl.Crypto" + ModuleProvider = require "l0.DependencyControl.ModuleProvider" + Stub = require "l0.DependencyControl.Stub" + + 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.#{DepCtrl.__name}.#{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: (controls\requireTest "Timer")! + + BadMutex: (controls\requireTest "BadMutex")! + + 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 + + -- await(onProgress) registers the callback for that run only and removes it before + -- returning, so it never leaks into a later await. It follows the EventEmitter convention + -- of receiving the emitter (the downloader) ahead of the percent. + + await_onProgressAutoBinds: (ut) -> + dm = fakeManager makeFakeDriver 2, {} + dm\addDownload "http://x/1", "#{basePath}_apb1" + seen = {} + dm\await (downloader, percent) -> + ut\assertEquals downloader, dm -- the emitter is passed through per the convention + seen[#seen + 1] = percent + ut\assertGreaterThan #seen, 0 + ut\assertEquals type(seen[1]), "number" + + -- a second await without a callback must not re-invoke the first run's listener + priorCount = #seen + dm\clear! + dm\addDownload "http://x/2", "#{basePath}_apb2" + dm\await! + ut\assertEquals #seen, priorCount + + -- 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", "await_onProgressAutoBinds", + "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: (controls\requireTest "Lock")! + + 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: (controls\requireTest "Record")! + + Updater: (controls\requireTest "Updater")! + + 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 + + FfiPosix: (controls\requireTest "ffi-posix")! + + 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/BadMutex.moon b/modules/l0/DependencyControl/test/BadMutex.moon new file mode 100644 index 0000000..de190f7 --- /dev/null +++ b/modules/l0/DependencyControl/test/BadMutex.moon @@ -0,0 +1,54 @@ +-- BadMutex tests: extracted from the main test suite. +-- Called from test.moon as: (controls\requireTest "BadMutex")! +() -> + BadMutex = require "l0.DependencyControl.shims.BadMutex" + + { + _description: "Tests for BadMutex: FFI-based process-scoped mutex (over a single named semaphore) that fills in for BM.BadMutex." + + -- API surface + + api_hasTryLock: (ut) -> + ut\assertFunction BadMutex.tryLock + + api_hasLock: (ut) -> + ut\assertFunction BadMutex.lock + + api_hasUnlock: (ut) -> + ut\assertFunction BadMutex.unlock + + -- tryLock / unlock round-trip + + tryLock_acquires: (ut) -> + result = BadMutex.tryLock! + ut\assertTrue result + BadMutex.unlock! -- release so subsequent tests start clean + + tryLock_failsWhenHeld: (ut) -> + ut\assertTrue BadMutex.tryLock! -- acquire + result = BadMutex.tryLock! -- second attempt must fail + BadMutex.unlock! + ut\assertFalse result + + unlock_releasesLock: (ut) -> + ut\assertTrue BadMutex.tryLock! + BadMutex.unlock! + result = BadMutex.tryLock! -- must succeed again after release + BadMutex.unlock! + ut\assertTrue result + + -- BM.BadMutex alias + + registered_asBadMutex: (ut) -> + -- DepCtrl registers "BM.BadMutex" as an alias via ModuleProvider; requiring it + -- should resolve to the bundled FFI mutex (or a native one if installed). + bm = require "BM.BadMutex" + ut\assertNotNil bm + ut\assertFunction bm.tryLock + + _order: { + "api_hasTryLock", "api_hasLock", "api_hasUnlock", + "tryLock_acquires", "tryLock_failsWhenHeld", "unlock_releasesLock", + "registered_asBadMutex" + } + } diff --git a/modules/l0/DependencyControl/test/Common.moon b/modules/l0/DependencyControl/test/Common.moon new file mode 100644 index 0000000..b904226 --- /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, "include" + + 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/Lock.moon b/modules/l0/DependencyControl/test/Lock.moon new file mode 100644 index 0000000..17f7ef9 --- /dev/null +++ b/modules/l0/DependencyControl/test/Lock.moon @@ -0,0 +1,428 @@ +-- Lock tests: extracted from the main test suite. +-- Called from test.moon as: (controls\requireTest "Lock")! +() -> + Lock = require "l0.DependencyControl.Lock" + Logger = require "l0.DependencyControl.Logger" + + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + TIMER_MODULE_NAME = "l0.DependencyControl.Timer" + + -- A controllable stand-in for the OS lock primitive, installed via the + -- Lock.createPrimitive seam so Lock tests never open a real OS handle. Spy/stub its + -- methods with ut\stub. + makeFakeSemaphore = (isOpen = true) -> + { + isOpen: isOpen + tryLock: => true + lock: => true + unlock: => true + } + + -- Installs a fake lock primitive through the Lock.createPrimitive seam for the next Lock + -- constructed in this test. tryLockBehavior is either a fixed boolean (returned every + -- call) or a function used as the stub implementation. Returns the fake plus the + -- tryLock/unlock stubs for assertions. + installFakeSemaphore = (ut, tryLockBehavior = true) -> + sem = makeFakeSemaphore! + tryLockStub = ut\stub sem, "tryLock" + if type(tryLockBehavior) == "function" + tryLockStub\calls tryLockBehavior + else + tryLockStub\returns tryLockBehavior + unlockStub = ut\stub sem, "unlock" + (ut\stub Lock, "createPrimitive")\returns sem + return sem, tryLockStub, unlockStub + + -- Minimal JSON holder record for exercising Lock's lease/stale-holder logic via a + -- stubbed FileOps.readFile. + craftHolderRecord = (acquiredAt, expiresAt) -> + ('{"holderName":"Ghost","pid":4321,"acquiredAt":%d,"expiresAt":%d}')\format acquiredAt, expiresAt + + { + _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) -> + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + lock\lock! + ut\assertEquals lock\getState!, Lock.LockState.Held + lock\release! + + -- LockScope enum + + scope_values: (ut) -> + ut\assertEquals Lock.Scope.Process, "process" + ut\assertEquals Lock.Scope.Global, "global" + + scope_defaultsToProcess: (ut) -> + lock = Lock namespace: "ns", resource: "res", recordHolder: false + ut\assertEquals lock.scope, Lock.Scope.Process + + scope_globalOption: (ut) -> + installFakeSemaphore ut, true + lock = Lock namespace: "ns", resource: "res", scope: Lock.Scope.Global, recordHolder: false + ut\assertEquals lock.scope, Lock.Scope.Global + + -- lock + + lock_success: (ut) -> + _, tryLockStub = installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + state, timePassed = lock\lock! + ut\assertEquals state, Lock.LockState.Held + ut\assertEquals timePassed, 0 + tryLockStub\assertCalledOnce! + lock\release! + + lock_alreadyHeld: (ut) -> + _, tryLockStub = installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + lock\lock! -- acquire + state, timePassed = lock\lock! -- re-enter: already held path + ut\assertEquals state, Lock.LockState.Held + tryLockStub\assertCalledOnce! -- semaphore not re-acquired on second call + lock\release! + + lock_timeout: (ut) -> + _, tryLockStub = installFakeSemaphore ut, false + sleepStub = ut\stub TIMER_MODULE_NAME, "sleep" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + 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 = installFakeSemaphore ut, -> + callCount += 1 + callCount >= 2 -- fails first, succeeds second + sleepStub = ut\stub TIMER_MODULE_NAME, "sleep" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + state, timePassed = lock\lock! + ut\assertEquals state, Lock.LockState.Held + tryLockStub\assertCalledTimes 2 + sleepStub\assertCalledOnceWith 250 -- default lockWaitInterval + lock\release! + + -- a missing OS primitive degrades to a process-local grant rather than failing + lock_primitiveUnavailable: (ut) -> + sem = makeFakeSemaphore false -- isOpen = false + (ut\stub Lock, "createPrimitive")\returns sem + ut\stub Lock.logger, "warn" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + state = lock\lock 0 + ut\assertEquals state, Lock.LockState.Held + lock\release! -- release so the lingering held state can't fire its GC warning in a later test + + -- tryLock + + tryLock_success: (ut) -> + _, tryLockStub = installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + state, timePassed = lock\tryLock! + ut\assertEquals state, Lock.LockState.Held + tryLockStub\assertCalledOnce! + lock\release! + + tryLock_fail: (ut) -> + _, tryLockStub = installFakeSemaphore ut, false + ut\stub TIMER_MODULE_NAME, "sleep" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + state, timePassed = lock\tryLock! + ut\assertEquals state, Lock.LockState.Unavailable + tryLockStub\assertCalledOnce! + + -- distinct resources map to distinct semaphores, so they can be held at once; + -- the same resource is mutually exclusive across instances. Uses real semaphores. + multiResource_independent: (ut) -> + ut\stub Lock.logger, "trace" + a = Lock namespace: "ns", resource: "resA", recordHolder: false + b = Lock namespace: "ns", resource: "resB", recordHolder: false + ut\assertEquals (a\tryLock!), Lock.LockState.Held + ut\assertEquals (b\tryLock!), Lock.LockState.Held -- different resource doesn't block + a\release! + b\release! + + sameResource_mutuallyExclusive: (ut) -> + ut\stub Lock.logger, "trace" + a = Lock namespace: "ns", resource: "shared", recordHolder: false + b = Lock namespace: "ns", resource: "shared", recordHolder: false + ut\assertEquals (a\tryLock!), Lock.LockState.Held + ut\assertEquals (b\tryLock!), Lock.LockState.Unavailable -- held by a + a\release! + ut\assertEquals (b\tryLock!), Lock.LockState.Held -- available after release + b\release! + + -- release + + release_held: (ut) -> + _, _, unlockStub = installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + lock\lock! + result, extra = lock\release! + ut\assertTrue result + ut\assertEquals extra, Lock.LockState.Available + unlockStub\assertCalledOnce! + + release_notHeld: (ut) -> + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + result, err = lock\release! + ut\assertNil result + ut\assertString err + ut\assertContains err, "not currently held" + + -- holder side file: written on acquire, removed on release + + holderRecorded_onLock: (ut) -> + written = {} + (ut\stub FILEOPS_MODULE_NAME, "writeFile")\calls (path, data) -> + written.path, written.data = path, data + true + removeStub = ut\stub FILEOPS_MODULE_NAME, "remove" + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", holderName: "TestHolder" + lock\lock! + ut\assertString written.data + ut\assertContains written.data, "TestHolder" + lock\release! + removeStub\assertCalledOnce! -- holder file cleared on release + + holderRecordsLease: (ut) -> + written = {} + (ut\stub FILEOPS_MODULE_NAME, "writeFile")\calls (path, data) -> + written.data = data + true + ut\stub FILEOPS_MODULE_NAME, "remove" + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", expiresAfter: 120 + lock\lock! + ut\assertContains written.data, "expiresAt" -- lease stamped into the record + ut\assertContains written.data, "acquiredAt" + lock\release! + + -- Global scope uses a real OS advisory file lock for cross-instance exclusion + globalScope_mutuallyExclusive: (ut) -> + ut\stub Lock.logger, "trace" + a = Lock namespace: "ns", resource: "globalShared", scope: Lock.Scope.Global, recordHolder: false + b = Lock namespace: "ns", resource: "globalShared", scope: Lock.Scope.Global, recordHolder: false + ut\assertEquals (a\tryLock!), Lock.LockState.Held + ut\assertEquals (b\tryLock!), Lock.LockState.Unavailable -- held by a (same file) + a\release! + ut\assertEquals (b\tryLock!), Lock.LockState.Held -- available after release + b\release! + + -- stale-holder warning: honors the holder's recorded lease + + staleHolder_warnsPastLease: (ut) -> + now = os.time! + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns craftHolderRecord now - 1000, now - 10 + installFakeSemaphore ut, false -- never acquires: takes the heldByOther path + ut\stub TIMER_MODULE_NAME, "sleep" + warnStub = ut\stub Lock.logger, "warn" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" -- recordHolder defaults true + lock\lock 0 + warnStub\assertCalled! -- lease lapsed -> stale warning + + staleHolder_silentWithinLease: (ut) -> + now = os.time! + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns craftHolderRecord now - 10, now + 1000 + installFakeSemaphore ut, false + ut\stub TIMER_MODULE_NAME, "sleep" + warnStub = ut\stub Lock.logger, "warn" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + lock\lock 0 + warnStub\assertNotCalled! -- still within the holder's lease + + overrideExpiry_usesOwnExpiry: (ut) -> + now = os.time! + -- holder claims a long lease, but overrideExpiry judges against our short expiresAfter + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns craftHolderRecord now - 1000, now + 100000 + installFakeSemaphore ut, false + ut\stub TIMER_MODULE_NAME, "sleep" + warnStub = ut\stub Lock.logger, "warn" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", overrideExpiry: true, expiresAfter: 10 + lock\lock 0 + warnStub\assertCalled! -- our (acquiredAt + 10) deadline has passed + + -- renew + + renew_forceRewrites: (ut) -> + writes = {} + (ut\stub FILEOPS_MODULE_NAME, "writeFile")\calls (path, data) -> + writes[#writes + 1] = data + true + ut\stub FILEOPS_MODULE_NAME, "remove" + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + lock\lock! -- writes holder record (#1) + ok = lock\renew -1 -- -1 forces a rewrite (#2) + ut\assertTrue ok + ut\assertEquals #writes, 2 + lock\release! + + renew_notHeld: (ut) -> + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + ok, err = lock\renew! + ut\assertNil ok + ut\assertString err + + renew_skipsWhenFresh: (ut) -> + writes = {} + (ut\stub FILEOPS_MODULE_NAME, "writeFile")\calls (path, data) -> + writes[#writes + 1] = data + true + ut\stub FILEOPS_MODULE_NAME, "remove" + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", expiresAfter: 600 + lock\lock! + renewed = lock\renew! -- default threshold: lease barely started + ut\assertFalse renewed + ut\assertEquals #writes, 1 + lock\release! + + renew_renewsWhenDue: (ut) -> + writes = {} + (ut\stub FILEOPS_MODULE_NAME, "writeFile")\calls (path, data) -> + writes[#writes + 1] = data + true + ut\stub FILEOPS_MODULE_NAME, "remove" + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", expiresAfter: 600 + lock\lock! + lock._leaseExpiresMono = 0 -- force remaining time below the threshold + renewed = lock\renew! + ut\assertTrue renewed + ut\assertEquals #writes, 2 + lock\release! + + -- guard: scoped acquire + guaranteed release + + guard_runsAndReleases: (ut) -> + _, _, unlockStub = installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + ran = false + result = Lock\guard {namespace: "ns", resource: "res", recordHolder: false}, (lock) -> + ran = true + "value" + ut\assertTrue ran + ut\assertEquals result, "value" + unlockStub\assertCalledOnce! -- released after the body + + guard_releasesOnError: (ut) -> + _, _, unlockStub = installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + ok, err = pcall -> Lock\guard {namespace: "ns", resource: "res", recordHolder: false}, -> error "boom" + ut\assertFalse ok -- the body's error is re-raised + unlockStub\assertCalledOnce! -- but the lock was still released + + guard_failsToAcquire: (ut) -> + installFakeSemaphore ut, false + ut\stub TIMER_MODULE_NAME, "sleep" + ut\stub Lock.logger, "trace" + called = false + result, err = Lock\guard {namespace: "ns", resource: "res", recordHolder: false, timeout: 0}, -> called = true + ut\assertNil result + ut\assertString err + ut\assertFalse called -- body never runs when the lock can't be taken + + -- GC canary: unreleased lock is cleaned up and warns on collection + + gc_canary: (ut) -> + sem = makeFakeSemaphore! + (ut\stub sem, "tryLock")\returns true + unlockStub = ut\stub sem, "unlock" + (ut\stub Lock, "createPrimitive")\returns sem + warned = false + warnStub = (ut\stub Lock.logger, "warn")\calls -> warned = true + ut\stub Lock.logger, "trace" + do + lock = Lock namespace: "ns", resource: "res", recordHolder: false + lock\lock! + -- run GC until the canary finalizer fires; a backlog of finalizers from earlier + -- tests means a fixed couple of passes isn't always enough + for _ = 1, 20 + collectgarbage "collect" + break if warned + warnStub\assertCalledOnce! + unlockStub\assertCalledOnce! + + _order: { + "lockState_values", "lockState_name", + "classLogger_fileBaseName", + "new_defaults", "new_customLogger", + "getState_initial", "getState_held", + "scope_values", "scope_defaultsToProcess", "scope_globalOption", + "lock_success", "lock_alreadyHeld", "lock_timeout", "lock_retry", + "lock_primitiveUnavailable", + "tryLock_success", "tryLock_fail", + "multiResource_independent", "sameResource_mutuallyExclusive", + "release_held", "release_notHeld", + "holderRecorded_onLock", "holderRecordsLease", + "globalScope_mutuallyExclusive", + "staleHolder_warnsPastLease", "staleHolder_silentWithinLease", "overrideExpiry_usesOwnExpiry", + "renew_forceRewrites", "renew_notHeld", "renew_skipsWhenFresh", "renew_renewsWhenDue", + "guard_runsAndReleases", "guard_releasesOnError", "guard_failsToAcquire", + "gc_canary" + } + } diff --git a/modules/l0/DependencyControl/test/ModuleProvider.moon b/modules/l0/DependencyControl/test/ModuleProvider.moon new file mode 100644 index 0000000..8e67e0f --- /dev/null +++ b/modules/l0/DependencyControl/test/ModuleProvider.moon @@ -0,0 +1,78 @@ +-- 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}" + + -- Minimal fake of the DependencyControl class, sufficient for tests that pass it as the + -- `DependencyControl` argument to runInitializer (or need to verify it was forwarded). + makeDepCtrlClassMock = -> {__name: constants.DEPCTRL_NAME} + + -- Minimal table that satisfies ModuleProvider.isDepCtrlVersionRecord without + -- creating a real DependencyControl record (which has config/registry side effects). + makeDepCtrlRecordMock = -> {__class: makeDepCtrlClassMock!, checkVersion: ->} + + { + _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 module with no init hook is a no-op: returns false without touching the module + runInitializer_noInitHook: (ut) -> + ref = {version: "1.0.0"} + ut\assertFalse ModuleProvider.runInitializer(ref, makeDepCtrlClassMock!) + + -- an uninitialized module (raw .version) gets its initializer run with the DepCtrl class + runInitializer_runsWhenUninitialized: (ut) -> + dcMock = makeDepCtrlClassMock! + ref = {version: "raw-version-string"} + initStub = ut\stub ref, DEPCTRL_MODULE_INIT_HOOK_NAME + ModuleProvider.runInitializer ref, dcMock + initStub\assertCalledOnceWith dcMock + + -- a module whose .version is already a DepCtrl record must NOT be re-initialized + runInitializer_skipsWhenInitialized: (ut) -> + ref = {version: makeDepCtrlRecordMock!} + initStub = ut\stub ref, DEPCTRL_MODULE_INIT_HOOK_NAME + ModuleProvider.runInitializer ref, makeDepCtrlClassMock! + initStub\assertNotCalled! + + _order: { + "register_andGetProvider", "register_firstWins", + "registerRecord_normalizesAliases", "searcher_resolvesAliasToProvider", + "runInitializer_noInitHook", "runInitializer_runsWhenUninitialized", + "runInitializer_skipsWhenInitialized" + } + } diff --git a/modules/l0/DependencyControl/test/Record.moon b/modules/l0/DependencyControl/test/Record.moon new file mode 100644 index 0000000..a4bfe79 --- /dev/null +++ b/modules/l0/DependencyControl/test/Record.moon @@ -0,0 +1,214 @@ +-- Record tests: extracted from the main test suite. +-- Called from test.moon as: (controls\requireTest "Record")! +() -> + constants = require "l0.DependencyControl.Constants" + Common = require "l0.DependencyControl.Common" + Record = require "l0.DependencyControl.Record" + Stub = require "l0.DependencyControl.Stub" + + DEPCTRL_RECORDS_GLOBAL_KEY = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Records" + + uniqueName = (prefix) -> "#{prefix}_#{'%08X'\format math.random 0, 16^8-1}" + + { + _description: "Tests for Record, the core DependencyControl record class." + + ---@param ut UnitTest + _setup: (ut) -> + -- Snapshot the live registry keys so teardown can remove only what the tests added. + registry = _G[DEPCTRL_RECORDS_GLOBAL_KEY] + snapshot = {} + if registry + snapshot[k] = true for k, _ in pairs registry + {:snapshot} + + ---@param ut UnitTest + _teardown: (ut, ctx) -> + registry = _G[DEPCTRL_RECORDS_GLOBAL_KEY] + return unless registry and ctx + -- Collect first, then remove, to avoid modifying the table during iteration. + toRemove = [k for k, _ in pairs registry when not ctx.snapshot[k]] + registry[k] = nil for k in *toRemove + + 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: getRegisteredRecord is the public lookup; registration happens + -- internally (via the constructor), so these seed the process-global registry directly + -- with unique namespaces. Teardown removes every key not present at setup time. + + registry_getReturnsRegistered: (ut) -> + ns = uniqueName "regns" + rec = {namespace: ns} + _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = rec + ut\assertIs Record\getRegisteredRecord(ns), rec + + registry_getMissing: (ut) -> + ut\assertNil Record\getRegisteredRecord uniqueName "absent" + + registry_getSkipsVirtual: (ut) -> + ns = uniqueName "virtns" + _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = {namespace: ns, virtual: true} + ut\assertNil Record\getRegisteredRecord ns + + registry_returnsAfterUnvirtualized: (ut) -> + ns = uniqueName "virtns" + rec = {namespace: ns, virtual: true} + _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = rec + ut\assertNil Record\getRegisteredRecord ns + rec.virtual = false + ut\assertIs Record\getRegisteredRecord(ns), rec + + registry_getRegisteredReturnsCopy: (ut) -> + ns = uniqueName "allns" + rec = {namespace: ns} + _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = rec + records = Record\getAllRegisteredRecords! + ut\assertIs records[ns], rec + -- a shallow copy: mutating the returned table must not affect the live registry + records[ns] = nil + ut\assertIs _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns], rec + + registry_getRegisteredIncludesVirtual: (ut) -> + ns = uniqueName "allvirtns" + rec = {namespace: ns, virtual: true} + _G[DEPCTRL_RECORDS_GLOBAL_KEY][ns] = rec + ut\assertIs Record\getAllRegisteredRecords![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", + "registry_getRegisteredReturnsCopy", "registry_getRegisteredIncludesVirtual" + } + } 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/Timer.moon b/modules/l0/DependencyControl/test/Timer.moon new file mode 100644 index 0000000..298d0c4 --- /dev/null +++ b/modules/l0/DependencyControl/test/Timer.moon @@ -0,0 +1,89 @@ +-- Timer tests: extracted from the main test suite. +-- Called from test.moon as: (controls\requireTest "Timer")! +() -> + Timer = require "l0.DependencyControl.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 + + -- stopwatch: start / stop / reset + + stop_freezesElapsed: (ut) -> + t = Timer! + Timer.sleep 20 + t\stop! + a = t\timeElapsed! + Timer.sleep 20 + -- while stopped, the elapsed total must not advance + ut\assertEquals t\timeElapsed!, a + + start_resumesAfterStop: (ut) -> + t = Timer! + Timer.sleep 20 + frozen = t\stop!\timeElapsed! + t\start! + Timer.sleep 20 + -- resuming measurement adds to the time accumulated before the stop + ut\assertGreaterThan t\timeElapsed!, frozen + + reset_clearsAccumulated: (ut) -> + t = Timer! + Timer.sleep 20 + before = t\timeElapsed! + t\reset! + -- reset drops back to (near) zero, below the pre-reset total + ut\assertLessThan t\timeElapsed!, before + + -- getTime: shared monotonic clock + + getTime_isCallable: (ut) -> + ut\assertFunction Timer.getTime + + getTime_monotonic: (ut) -> + a = Timer.getTime! + Timer.sleep 5 + b = Timer.getTime! + ut\assertGreaterThanOrEquals b, a + + -- 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", + "stop_freezesElapsed", "start_resumesAfterStop", "reset_clearsAccumulated", + "getTime_isCallable", "getTime_monotonic", + "sleep_isCallable", "sleep_onClass", "sleep_onInstance" + } + } 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/Updater.moon b/modules/l0/DependencyControl/test/Updater.moon new file mode 100644 index 0000000..253570c --- /dev/null +++ b/modules/l0/DependencyControl/test/Updater.moon @@ -0,0 +1,121 @@ +-- Updater tests: extracted from the main test suite. +-- Called from test.moon as: (controls\requireTest "Updater")! +() -> + ffi = require "ffi" + Common = require "l0.DependencyControl.Common" + FileOps = require "l0.DependencyControl.FileOps" + Record = require "l0.DependencyControl.Record" + + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + + -- Drive prefix so stubbed paths are recognized as absolute on Windows too. + DRIVE = ffi.os == "Windows" and "C:" or "" + + -- Map the ?user / ?data tokens to distinct absolute roots so the two automation + -- directories differ (the non-portable case getEntryPointPath guards against). + stubDistinctRoots = (ut) -> + (ut\stub aegisub, "decode_path")\calls (path) -> + ((path\gsub "^%?user", "#{DRIVE}/user")\gsub "^%?data", "#{DRIVE}/data") + + -- fileOps.attributes stub that reports a "file" mode for exactly the given full paths. + stubFilesPresent = (ut, present) -> + set = {p, true for p in *present} + (ut\stub FILEOPS_MODULE_NAME, "attributes")\calls (path, key) -> + return "file", path if set[path] + return false, path + + -- Normalize a sub-path under a base dir the same way getPossibleEntryPointPaths does, + -- so expected paths in tests always match what the production code produces. + entryPath = (baseDir, subPath) -> FileOps.validateFullPath subPath, false, baseDir + + moduleRecord = { + scriptType: Common.ScriptType.Module, namespace: "l0.Foo", + getPossibleEntryPointPaths: Record.getPossibleEntryPointPaths, + getEntryPointPath: Record.getEntryPointPath + } + macroRecord = { + scriptType: Common.ScriptType.Automation, namespace: "l0.Foo.Bar", + getPossibleEntryPointPaths: Record.getPossibleEntryPointPaths, + getEntryPointPath: Record.getEntryPointPath + } + + { + _description: "Tests for Record.getEntryPointPath: locates a record's entry point and reports whether it is under the ?user or ?data automation directory." + + -- a module present only under ?data: found there, isUserPath = false + module_dataOnly: (ut) -> + stubDistinctRoots ut + dataDir = aegisub.decode_path "?data/automation/include" + expected = entryPath dataDir, "l0/Foo.moon" + stubFilesPresent ut, {expected} + path, isUserPath = moduleRecord\getEntryPointPath! + ut\assertEquals path, expected + ut\assertFalse isUserPath + + -- modules may also be deployed as /init.ext + module_initLayout: (ut) -> + stubDistinctRoots ut + dataDir = aegisub.decode_path "?data/automation/include" + expected = entryPath dataDir, "l0/Foo/init.lua" + stubFilesPresent ut, {expected} + path, isUserPath = moduleRecord\getEntryPointPath! + ut\assertEquals path, expected + ut\assertFalse isUserPath + + -- ?user copy takes precedence: found there, isUserPath = true + module_alsoUnderUser: (ut) -> + stubDistinctRoots ut + userDir = aegisub.decode_path "?user/automation/include" + dataDir = aegisub.decode_path "?data/automation/include" + expected = entryPath userDir, "l0/Foo.moon" + stubFilesPresent ut, {expected, entryPath(dataDir, "l0/Foo.moon")} + path, isUserPath = moduleRecord\getEntryPointPath! + ut\assertEquals path, expected + ut\assertTrue isUserPath + + module_userOnly: (ut) -> + stubDistinctRoots ut + userDir = aegisub.decode_path "?user/automation/include" + expected = entryPath userDir, "l0/Foo.moon" + stubFilesPresent ut, {expected} + path, isUserPath = moduleRecord\getEntryPointPath! + ut\assertEquals path, expected + ut\assertTrue isUserPath + + -- not installed anywhere yet → both return values are nil + notInstalled: (ut) -> + stubDistinctRoots ut + stubFilesPresent ut, {} + path, isUserPath = moduleRecord\getEntryPointPath! + ut\assertNil path + ut\assertNil isUserPath + + -- portable / "Local Config": ?user and ?data resolve to the same directory, so the file + -- is found under ?user first and isUserPath is always true + portable: (ut) -> + (ut\stub aegisub, "decode_path")\calls (path) -> + ((path\gsub "^%?user", "#{DRIVE}/same")\gsub "^%?data", "#{DRIVE}/same") + sameDir = aegisub.decode_path "?user/automation/include" + expected = entryPath sameDir, "l0/Foo.moon" + stubFilesPresent ut, {expected} + path, isUserPath = moduleRecord\getEntryPointPath! + ut\assertEquals path, expected + ut\assertTrue isUserPath + + -- automation scripts live as a single file in the autoload directory + macro_dataOnly: (ut) -> + stubDistinctRoots ut + dataDir = aegisub.decode_path "?data/automation/autoload" + expected = entryPath dataDir, "l0.Foo.Bar.lua" + stubFilesPresent ut, {expected} + path, isUserPath = macroRecord\getEntryPointPath! + ut\assertEquals path, expected + ut\assertFalse isUserPath + + _order: { + "module_dataOnly", "module_initLayout", + "module_alsoUnderUser", "module_userOnly", + "notInstalled", "portable", + "macro_dataOnly" + } + } diff --git a/modules/l0/DependencyControl/test/ffi-posix.moon b/modules/l0/DependencyControl/test/ffi-posix.moon new file mode 100644 index 0000000..9a3120f --- /dev/null +++ b/modules/l0/DependencyControl/test/ffi-posix.moon @@ -0,0 +1,58 @@ +-- POSIX-only tests for helpers/ffi-posix: validates the open(2) flag/mode values against +-- the real kernel by actually opening files. Skipped on Windows (see _condition), where +-- the values are never used. Run it via the WSL test runner. +-- Called from test.moon as: (controls\requireTest "FfiPosix")! +() -> + ffi = require "ffi" + ffiPosix = require "l0.DependencyControl.helpers.ffi-posix" + lfs = require "lfs" + + pcall ffi.cdef, [[ + int open(const char *path, int flags, int mode); + int close(int fd); + unsigned int umask(unsigned int mask); + ]] + + Access = ffiPosix.FileAccessMode + Create = ffiPosix.FileCreationFlags.Create + Exclusive = ffiPosix.FileCreationFlags.Exclusive + + tmpPath = -> aegisub.decode_path "?temp/depctrl_ffiposix_#{'%08X'\format math.random 0, 16^8-1}" + + { + _description: "POSIX open(2) flag/mode values (helpers/ffi-posix), validated against the real kernel." + -- only meaningful on POSIX; the flag values are never exercised on Windows + _condition: -> ffi.os != "Windows", "POSIX-only" + + -- O_WRONLY | O_CREAT actually creates a file (proves the access mode + Create values) + create_makesFile: (ut) -> + path = tmpPath! + fd = ffi.C.open path, bit.bor(Access.Write, Create), ffiPosix.getFileMode "rw" + ut\assertGreaterThanOrEquals fd, 0 + ffi.C.close fd if fd >= 0 + ut\assertEquals lfs.attributes(path, "mode"), "file" + os.remove path + + -- O_CREAT | O_EXCL fails when the file already exists (proves the Exclusive value) + exclusive_failsOnExisting: (ut) -> + path = tmpPath! + fd1 = ffi.C.open path, bit.bor(Access.Write, Create), ffiPosix.getFileMode "rw" + ffi.C.close fd1 if fd1 >= 0 + fd2 = ffi.C.open path, bit.bor(Access.Write, Create, Exclusive), ffiPosix.getFileMode "rw" + ut\assertTrue fd2 < 0 -- EEXIST + ffi.C.close fd2 if fd2 >= 0 + os.remove path + + -- getFileMode's bits become the real on-disk permissions (with the umask cleared) + getFileMode_setsPermissions: (ut) -> + oldMask = ffi.C.umask 0 + path = tmpPath! + fd = ffi.C.open path, bit.bor(Access.Write, Create), ffiPosix.getFileMode "rw", "r", "r" + ffi.C.close fd if fd >= 0 + ffi.C.umask oldMask + perms = lfs.attributes path, "permissions" + os.remove path + ut\assertEquals perms, "rw-r--r--" -- 0o644 + + _order: { "create_makesFile", "exclusive_failsOnExisting", "getFileMode_setsPermissions" } + } diff --git a/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon b/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon new file mode 100644 index 0000000..64a5f3e --- /dev/null +++ b/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon @@ -0,0 +1,111 @@ +-- 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 only once the server actually answers HTTP. A bare TCP connect succeeds as + -- soon as the kernel accepts into the listen backlog (which can happen before copas + -- starts dispatching requests). + isServing = -> + conn = socket.tcp! + conn\settimeout 0.5 + unless conn\connect "127.0.0.1", port + conn\close! + return false + conn\send "GET /status/200 HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" + statusLine = conn\receive "*l" + conn\close! + return statusLine != nil and statusLine\match("^HTTP/") != nil + + deadline = socket.gettime! + @timeout + while socket.gettime! < deadline + return @ if isServing! + 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..c320923 --- /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. Used to sort object keys not present in +--- `state.keyorder` (which takes precedence). Default is case-insensitive alphabetical, and currently only +--- applies in the 'prettier' indent mode. +---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 json 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" + } + } + } + } +}