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/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..0761551 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,89 @@ +name: Docs + +on: + push: + branches: + - 'master' + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +# gh-pages deployments append commits to a shared branch; serialize them +concurrency: + group: docs-deploy + cancel-in-progress: false + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # mike commits the built site onto the gh-pages branch + fetch-depth: 0 + + - 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 + # json schema validation + - run: luarocks install lua-schema + - run: luarocks install lpeg + + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - run: pip install mkdocs-material mike + + - name: Generate docs + timeout-minutes: 5 + run: lua depctrl.lua generate-docs + + - name: Determine version slug + id: version + run: | + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + echo "slug=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "release=true" >> "$GITHUB_OUTPUT" + else + # branch deploys (master pushes and manual dispatches) use the branch name, + # with slashes flattened so the slug stays a single path segment + echo "slug=${GITHUB_REF_NAME//\//-}" >> "$GITHUB_OUTPUT" + echo "release=false" >> "$GITHUB_OUTPUT" + fi + + - name: Configure committer + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Publish to gh-pages + working-directory: build/docs + run: | + slug='${{ steps.version.outputs.slug }}' + if [[ "${{ steps.version.outputs.release }}" == "true" ]]; then + # releases also move the 'latest' alias, and the site root redirects to it + mike deploy --push --update-aliases "$slug" latest + mike set-default --push latest + else + mike deploy --push "$slug" + # Before the first release there is no 'latest' for the root to point at, so a master + # deploy claims the root; once a release exists it owns the root and master leaves it be. + # Feature-branch previews never touch the root. + if [[ "${GITHUB_REF}" == "refs/heads/master" ]] && ! mike list | grep -qw latest; then + mike set-default --push "$slug" + fi + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1a8e732 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,74 @@ +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 }} + + # Lint the module sources' LuaCATS annotations: missing docs on public members and + # @param/@return that disagree with the real signature fail the build. This also emits every + # definition and verifies it is loadable Lua, so it doubles as type-generation coverage. + - name: Check annotations + if: ${{ !cancelled() }} + run: lua depctrl.lua generate-types --check + + # Smoke-test that documentation generation runs over the real feed without a parse or emit + # failure. --site none skips scaffolding; the output goes to a throwaway directory. + - name: Smoke-test doc generation + if: ${{ !cancelled() }} + run: lua depctrl.lua generate-docs --site none -o "$RUNNER_TEMP/docs" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98d6f7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/ctrf/*.json +/dist/ +/DependencyControl-*.zip +/build/ +/types/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aed234f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "files.insertFinalNewline": true, + "Lua.runtime.version": "LuaJIT", + "Lua.workspace.library": ["${workspaceFolder}/types"], + "Lua.workspace.checkThirdParty": false, + "Lua.diagnostics.globals": [ + "version" + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a2e0969 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,194 @@ +# Agent guidelines for DependencyControl + +## Annotations + +Write annotations to inform callers: what the method does, what to pass in, what comes back, and any side effects worth knowing about. Do not write implementation reports, session history, or rationale for design decisions made in a prior conversation — those belong in commit messages or PR descriptions. The governing test for every comment, doc or inline, is the cold-reader test in [Inline comments](#inline-comments): if it only makes sense given our discussion, it doesn't belong in the code. + +A comment is allowed to mention a non-obvious constraint or design choice *if it directly affects how callers use the API*. Example: "Automation scripts have their tests registered and updates scheduled when they call `registerMacros` through DependencyControl." That is a behavioral contract. "This replaces the old per-environment scheduling that caused lock contention" is not. + +### Length and altitude + +A doc comment is the **contract**, not a paraphrase of the body: what the caller passes, what comes back, and any side effect — at the highest altitude that still informs. This is a separate failure mode from narration: a description can be free of design rationale and *still* be wrong for being a verbose restatement of the code. + +- **Default to one line.** If one line says it, the method gets one line — no matter how long its body is. Add a second line only for a genuinely separate contract (a precondition, a side effect), never to pad. +- **Never restate the body.** "a required dependency errors with `-6`, an optional one returns `3`" is just the `if`/`else` in prose, and the `@return` already carries the codes. Describe the *outcome* a caller relies on, not the branches that produce it. +- **Stay inside this method.** Don't narrate what *another* method does ("(run acquires the lock first)"); state only the contract on this call ("the lock must already be held"). +- **Every `@return` gets a description**, not just a type (the most-skipped mandatory rule). Resolve its apparent clash with "never restate the body" by dividing the labor: the summary states the method's purpose and any non-obvious behavior, and the `@return` states the concrete shape and edge conditions the summary omits, such as the nil case, ordering, or emptiness. For a trivial side-effect-free getter that genuinely leaves nothing to add, accept a little overlap. The `@return` is surfaced at call sites on its own, so a concise shape label earns its place even when it echoes the summary. What stays banned is padding either side with invented detail just to force a difference, or skipping the shape/edge detail when there *is* some. +- **Clustered punctuation is a smell**, in inline `--` comments as much as doc comments. No single mark is banned — parentheses, semicolons, colons, and em dashes are all fine on their own. But when they pile up in one sentence, the thought usually needs reorganizing. Watch for a semicolon nested inside a parenthetical (`(sorted; empty when none)`), stacked or back-to-back parentheticals, an em-dash aside dropped into a clause that already carries parens or a semicolon, a closing paren shoved against a semicolon (`(nil if unreachable); defaults…`), or a `label:` headline that hangs the explanation off a colon like a blog subhead (`next, not pairs: a subclass…`). The fix is one aside per sentence: split the rest into a clean clause or a second sentence. + +### Identifier references in prose + +Prefer natural language over backticked identifier names in a doc comment's prose. A `---@param feedUrl` is already declared, so the description reads "the given feed URL", not "`feedUrl`"; likewise drop reflexive "(see `otherMethod`)" pointers when a plain phrase carries the meaning. Reserve a backticked reference for when the exact name is what the caller acts on — a config key they set (`extraFeeds`, `fetchUntrustedFeeds`), an enum value the result equals, or a field they inspect (`stats.truncated`) — not as an echo of a declared parameter or a cross-link to another method. + +- Prefer: Returns the namespaces of installed packages that effectively update from the given feed URL. +- Avoid: Returns the namespaces of installed packages that effectively update from `feedUrl` (see `getEffectiveSource`). + +Why: prose reads more easily, and LuaCATS has no inline reference/link syntax — a backticked name is just literal text (and LuaLS doc highlighting doesn't render for `.moon` files anyway), so the backticks buy nothing but clutter. + +### Naming + +Name a method by what it is to the caller, not by a part-of-speech rule: + +- **Commands** (do work, have effects) take a **verb**: `reportNoSuitablePackage`, `getEffectiveSource`, `persistSource`. +- **Value producers** — constructors, converters, selectors, predicates — may lead with a preposition or copula that signals the transformation or relationship: `toString`, `fromJSON`, `withoutInstall`, `withTestExports`, `atIndex`, `isBlocked`, `hasTeardown`. + +The one hard ban: a **bare noun for a method that acts** — `failures`, `noSuitablePackage` read as fields, not operations. Litmus: read the name as a field access (`obj.name`); if the method does work but the name looks like stored data, rename it. + +### LuaCATS / LuaLS style — always + +Use [LuaCATS](https://github.com/LuaLS/lua-language-server/wiki/Annotations) for all doc comments. +Do **not** use _ldoc_, even though it appears in older parts of the codebase. + +| ldoc (never) | LuaCATS (always) | +|----------------------------------|-------------------------------------------| +| `-- @tparam string x desc` | `---@param x string desc` | +| `-- @treturn boolean` | `---@return boolean` | +| `-- @classmod Foo` | `---@class Foo` | +| `-- @param[opt] x` | `---@param x? type` | + +Always document every parameter and return value. Use `---@param name? type` for optional params. +Class-level docs go on a `---@class` line immediately before the class definition. + +### Enum-typed values + +**Define new enumerated sets with the DependencyControl `Enum` class** (`Enum "Name", {Key: value}`), not a plain Lua table. The `Enum` class gives immutability and value/key reverse lookup. (`Common.ScriptType`/`RecordType` were migrated to string-valued Enums; the only bare lookup tables left are deliberate presentation maps — dialog label/glyph tables and `Common.terms` — which are not value domains.) + +When a parameter, field, or return holds a value from a DependencyControl `Enum` (e.g. `Common.ScriptType` or a band/mode enum), give it a named LuaCATS type instead of annotating it as a bare `integer`/`string` with a prose hint. `Enum` instances are built at runtime via `Enum "Name", {...}`, so LuaLS can't infer their members and `---@enum` (which needs a literal table) doesn't apply — declare an `---@alias` with one `---| # : ` line per member directly above the `Enum`, and reference that name in annotations. Keep the per-member descriptions in the alias (single source of truth) and leave the runtime table bare: + +```moon +---@alias UpdaterTrustBand +---| 1 # DeclaredDirect: the declared/own feed, trusted, offering the module by name +---| 2 # TrustedDirect: another trusted feed, offering the module by name +TrustBand = Enum "UpdaterTrustBand", { + DeclaredDirect: 1 + TrustedDirect: 2 +} + +---@param band UpdaterTrustBand +``` + +### Inline comments + +Use inline `--` comments only when the **why** is non-obvious: a hidden constraint, a subtle invariant, a language-level gotcha. Never describe *what* the code does — well-named identifiers already do that — nor restate what's obvious at a glance, like that `@base` is the method table or that a setter parses its argument. + +**Litmus test — write for a reader who has never seen our conversation.** Before keeping a comment, ask: does it make sense to someone reading the file cold, with no access to our chat? A comment must describe the code *as it is* — a constraint it relies on or a hazard it guards against. It must **not** justify the implementation by comparing it to an alternative, another construct, or a previous version — that "why this and not that?" only exists in our discussion. If you want to explain a design decision, tell me in chat or put it in the commit message; never in the code. + +Delete on sight any comment built on these tells (they signal you're narrating a decision, not documenting behavior): *"unlike …"*, *"rather than / instead of …"*, *"we now / no longer / used to …"*, *"this is better / cleaner because …"*, *"chosen / done this way so that …"*, *"moved here …"*. + +```moon +-- GOOD — states a constraint the code actually relies on; stands on its own: +-- intervals are half-open [min, max), so the greatest satisfying version is max - 1 +-- deep-copy so we don't mutate the shared default + +-- BAD — narrates a decision/comparison from our discussion; meaningless to a cold reader: +-- a categorical enum (unlike the ordinal PromptThreshold), so string values rather than numbers +-- moved here so requirers don't have to unwrap anything +``` + +## File naming + +Name a `.moon` file that defines and returns a single class after that class, in PascalCase (`FeedTrust.moon`, `Host.moon`). Name any other file — helpers that return a function or table, FFI shims, and the like — in kebab-case (`resolve-host.moon`, `ffi-posix.moon`). Test files mirror the name of what they test. + +## MoonScript gotchas + +**`or=` is a statement, not an expression.** A method whose entire body is `@field or= value` returns `nil`, not the assigned value. Always add an explicit `return @field` after a lazy-init `or=` when the caller expects a return value. + +**`@@field arg` compiles to a method call, not a plain call.** Calling a value stored in a static field with `@@field arg` emits `self.__class:field(arg)` — a colon call that passes the class as an implicit first argument. When the field holds a constructor or plain function (e.g. a lazily-required class you want to call as `Cls(args)`), write `@@.field arg` (or bind it to a local first): `@@.field` emits plain `self.__class.field(arg)`. The same applies to instance fields: `@field arg` → `self:field(arg)`, vs `@.field arg` → `self.field(arg)`. + +**A constructor's return value is discarded.** MoonScript's class `__call` always returns the freshly-built instance and ignores whatever `new` returns, so `Cls(...)` can never yield `(nil, err)` — a `return nil, code` inside `new` to signal "construction failed" is dead code. Validate *before* or *around* construction (e.g. in the factory/`addTask` that calls the constructor), not inside `new`. + +**Compile-check before handoff.** A `.moon` syntax error makes Aegisub's loader fall through to a misleading "module not found". After any `.moon` edit, run: + +```bash +moonc -p path/to/File.moon > /dev/null +``` + +Fix any parse errors before reporting the task done. Run the check on every changed file in the same turn, not just the last one edited. + +## Private methods and the public API + +Mark anything not meant to be called from outside its own class with `---@private` (LuaCATS). The language server then flags external `obj.method()` access while still allowing `@method`/`@@method` inside the class. Prefix the name with `__` as a visual signal. + +A method is public if it's a safe, stable building block a caller outside its own class could reasonably use — **whether or not anything in this codebase calls it yet**. DependencyControl is a framework, so the test is: *would a third party building on it — say, a richer install/update UI than the Toolbox — want this?* Clean queries and utilities (`UpdateTask.getTrustedFeeds`/`getBlockedFeeds`, `Updater.isRunning`, `UpdateFeed.getModule`) stay public with no current caller. Mark `---@private` only genuine implementation details: fragile, context-dependent steps that operate on internal formats — the resolution/install pipeline, the built-in prompt flow, logging helpers. An existing external caller proves a method public; the absence of one does not prove it private. Never narrow a method that was public in a prior release — that's a silent breaking change. Tests are exempt — they deliberately drive internals through the stub-self pattern (`Class.method(fakeSelf, …)`), which is expected. + +To **mock** an internal in a test, the test needs a field it can swap, so a mock-worthy dependency belongs in a `---@private` class field (`@@__dep`/`@__dep`), not a module-local upvalue (a `local` helper or `downloader = Downloader!`) that nothing outside the file can replace. The stub-self / class-stub patterns then swap the field. To merely **read** a private value or function from a test rather than replace it, use `withTestExports` (next section). + +## Exposing internals for tests + +Never add a test-only field or method to a module's public API (e.g. a class field) just so a test can reach it. Use the `UnitTestSuite` **test-export** helpers instead: `withTestExports` reveals a module's private internals to its tests and passes the module straight through, so it wraps the module's own `return`: + +```moon +-- at the end of MyModule.moon +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +return UnitTestSuite\withTestExports MyModule, {:somePrivate, :anotherPrivate} +``` + +The test file reads them straight from its own `require` — no routing through `Record\register` or the main test file: + +```moon +MyModule = require "l0.DependencyControl.MyModule" +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +{:somePrivate} = UnitTestSuite\getTestExports MyModule +``` + +Why this works: the exports are stashed on the module's metatable, so they're unreachable via normal `MyModule.x` indexing and stay off the public surface. Adding a non-metamethod key leaves the class's construction (`__call`) and inheritance (`__index`) metamethods intact, so requirers and `require` are unaffected — nothing needs to unwrap anything. + +## Computed properties + +A field whose read or write must run code — a derived value, or a stored value that needs conversion — is a computed property. Declare it with the `Accessors` module rather than hand-writing `__index`/`__newindex`, so the metatable manipulation lives in one audited place and each property is recorded on `Class.__accessors` for tooling. Mark the property in the class body and wire the class up once, immediately after the body (and, for a subclass, after the parent's `install` — a subclass inherits its parent's properties): + +```moon +Accessors = require "l0.DependencyControl.Accessors" + +class Record + version: Accessors.property + get: => @semanticVersion\toPacked! + set: (value) => @semanticVersion = SemanticVersion.fromPacked SemanticVersion\toPacked value + -- ...other methods... +Accessors.install Record +``` + +Omit `set` for a read-only property (a write raises). Two things follow from a property being computed rather than stored. A readable property **appears in `pairs(instance)`** with its getter value, like a raw field would — `install` adds a `__pairs` for this — so iterating runs each getter, and a getter that dereferences a nil backing field raises. So initialize the backing field before the property can be read (at the top of the constructor if the object is observable mid-construction, as records are via `createDummyRef`), and don't `pairs` a class's `__base` table, whose getters have no instance to run against. Separately, a property whose stored form differs from its runtime value needs explicit serialization: `Record.version` is a packed integer at runtime but a semver string in the config, so it's kept out of the generic `scriptFields` copy and converted by hand in `loadConfig`/`writeConfig`. + +Annotate the property's type with a `---@field` on the `---@class`, not through the `property{…}` call — the accessor is invisible plumbing the type system shouldn't see. LuaCATS has no read-only field marker (`@field` scopes are only `private`/`protected`/`public`/`package`), so note a read-only property in its `@field` description and let `install` enforce it at runtime. + +```moon +---@class Record +---@field semanticVersion SemanticVersion This record's version as a value object (the canonical store). +---@field version integer Packed-integer view over `semanticVersion`; assignable from a string, packed int, or instance. +``` + +## Module-private markers + +A module-private sentinel or marker key — the tag `Accessors` uses to spot its property specs, the value `ut\skip` raises to abort a test — is a namespaced string built from the shared prefix, not a bare `{}` or a plain name: `"#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}SomeName"`. It stays collision-free across the shared global/config namespace while remaining inspectable and serializable. + +## Dialog labels and dispatch + +Never branch on a raw dialog label. A comparison like `switch btn when "Fetch/Discover"` or `btn == "Yes"` welds the visible wording to the control flow: rewording the caption silently breaks the dispatch, and any test that hardcodes the same string breaks with it (and fails in a confusing place, not at the rename). Instead map every label the code acts on — button captions, dropdown values — to a stable key in one table, following the existing `glyphs` / `trustGlyphs` / `feedActionLabels` pattern, and branch on `table.key`. Build the widget's button list from that same table so the caption and the dispatch key can't drift apart. These label tables are plain tables (like `glyphs`), not `Enum`s — the `Enum` rule is for value domains, not presentation lookups. + +Expose any such map a test needs the same way as other internals — an automation script's `registerMacros` test exports, or a module's `withTestExports` — and have the test key off it too (`buttons.discover`, not `"Fetch/Discover"`). A caption change is then a one-line edit in the map, with the button list, the dispatch, and the tests all following automatically. Purely decorative maps a test never inspects (a glyph legend, a provenance description) can stay local to the function that renders them. + +## luarocks + +Never run `luarocks install` (or any state-modifying luarocks command) without explicit user permission. If a rock is needed, tell the user which one and suggest: + +```bash +luarocks --lua-version=5.1 install +``` + +Always pass `--lua-version=5.1`. The project uses LuaJIT with `DLUAJIT_ENABLE_LUA52COMPAT`, which is Lua 5.1 ABI-compatible; omitting the flag may resolve the wrong tree. + +## Changelog entries + +When making significant user-visible changes to a script or module, add changelog entries to `DependencyControl.json` under the current version key for the affected script. The format is an array of plain-English strings, one per notable change. Cover behavior changes, new features, and fixed bugs. Do **not** list internal refactorings, renamed variables, comment edits, or test-only changes. Keep each entry to one or two sentences. Examples of appropriate entries: + +```json +"0.7.0": [ + "Automatic update scheduling is now centralized in the DependencyControl Toolbox.", + "Record: Added getAllRegisteredRecords() to expose the live record registry to tooling." +] +``` + +## Markdown / prose + +Do not insert manual line breaks within a paragraph in Markdown files (README, docs, etc.). Write each paragraph and list item as a single unwrapped line and let the renderer handle wrapping. (Hard wrapping makes later edits produce noisy, ragged diffs.) diff --git a/DependencyControl.json b/DependencyControl.json index 90690ce..61e54c3 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -1,59 +1,84 @@ { - "dependencyControlFeedFormatVersion": "0.3.0", + "dependencyControlFeedFormatVersion": "0.4.0", "name": "DependencyControl", "description": "The official DependencyControl repository.", "baseUrl": "https://github.com/TypesettingTools/DependencyControl", "url": "@{baseUrl}", "fileBaseUrl": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/", + "fileBaseUrls": { + "script": "@{fileBaseUrl}v@{version}@{tagSuffix:@{channel}}/@{scriptTypeSection}/@{namespacePath}@{fileName}", + "test": "@{fileBaseUrl}v@{version}@{tagSuffix:@{channel}}/@{scriptTypeSection}/@{namespacePath}/test@{fileName}" + }, + "localFileBasePaths": { + "script": "@{localFileBasePath}@{scriptTypeSection}/@{namespacePath}@{fileName}", + "test": "@{localFileBasePath}@{scriptTypeSection}/@{namespacePath}/test@{fileName}" + }, + "vars": { + "tagSuffix": { + "alpha": "-alpha", + "release": "" + } + }, "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": { + "fileBaseUrls": { + "script": "@{fileBaseUrl}v@{version}@{tagSuffix:@{channel}}/@{scriptTypeSection}/@{namespace}@{fileName}", + "test": "@{fileBaseUrl}v@{version}@{tagSuffix:@{channel}}/@{scriptTypeSection}/@{namespace}/test@{fileName}" + }, + "localFileBasePaths": { + "script": "@{localFileBasePath}@{scriptTypeSection}/@{namespace}@{fileName}", + "test": "@{localFileBasePath}@{scriptTypeSection}/@{namespace}/test@{fileName}" + }, "l0.DependencyControl.Toolbox": { - "url": "@{baseUrl}#@{namespace}", - "author": "line0", "name": "DependencyControl Toolbox", "description": "Provides DependencyControl maintenance and configuration utilities.", - "fileBaseUrl": "@{fileBaseUrl}macros-v@{version}-@{channel}/macros/@{namespace}", + "author": "line0", + "url": "@{baseUrl}#@{namespace}", "channels": { "alpha": { - "version": "0.1.3", - "released": "2016-01-27", + "version": "0.3.0", + "released": null, "default": true, "files": [ { "name": ".moon", - "url": "@{fileBaseUrl}@{fileName}", - "sha1": "3677B2817C3D1FFE86981C8ABCC092B3D2CCEE7B" + "url": "@{fileBaseUrl}", + "sha1": "F2E05217B8C34B2FFAB02BC458C3F8E20055CAD9" + }, + { + "name": ".moon", + "url": "@{fileBaseUrl}", + "sha1": "3865388F0EF9E368187D5E6F6C3CC8400499F885", + "type": "test" } ], "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 +88,574 @@ ], "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." + ], + "0.3.0": [ + "Added a Manage Feeds macro to review and manage the feeds DependencyControl knows about and their trust status: trust, block (with a reason), unblock, or remove feeds; discover feeds advertised by other feeds; and manage your extra feeds and block list in dedicated panels. It warns before an action would change an installed package's update source, opens feed details in the DepCtrl Browser, and won't let you block DependencyControl's own feed.", + "Manage Feeds now shows a Fetched column with how long ago each feed was last retrieved into the cache, read straight from the cache so it's visible even before Discover — letting you see at a glance which feeds have recent data.", + "The Toolbox now ships a DependencyControl unit test suite, adding it to the Run Tests menu.", + "The startup update sweep no longer reports expected conditions, such as skipping the update of an unmanaged module, as unexpected scheduling errors.", + "Fixed the install/uninstall pickers erroring when an installed package's config entry lacks a version or name, as can happen with orphaned or unmanaged records." ] } } }, "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}", "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" + "url": "@{fileBaseUrl}", + "sha1": "B96362A625AF83FADA6FB0F452FBB6BDE76B9711" + }, + { + "name": "/Common.moon", + "url": "@{fileBaseUrl}", + "sha1": "551B80B4A1A47119F89AABFC8ECC4E52DDF3F37F" + }, + { + "name": "/Constants.moon", + "url": "@{fileBaseUrl}", + "sha1": "68762FCCFB4866C951F9506F1BBF2BBA37506FA0" }, { "name": "/ConfigHandler.moon", - "url": "@{fileBaseUrl}@{fileName}", - "sha1": "97BCD3207FE8158261FA7851057464535FCEFBC6" + "url": "@{fileBaseUrl}", + "sha1": "844FE28DB95A3FC0C47EBDCE49804FFCD01D6D7A" + }, + { + "name": "/ConfigView.moon", + "url": "@{fileBaseUrl}", + "sha1": "A520D21D5A6ACFFC21006536070EAD7A64061778" + }, + { + "name": "/Crypto.moon", + "url": "@{fileBaseUrl}", + "sha1": "6FF844EE6191D2ED86683981F9234B21F196DA3F" + }, + { + "name": "/Downloader.moon", + "url": "@{fileBaseUrl}", + "sha1": "E0809BA16F1948B6C55A3976630562E91318D7FA" + }, + { + "name": "/Enum.moon", + "url": "@{fileBaseUrl}", + "sha1": "5BFB3A3B14733BFE17FFF30F3F9D692520A606BF" + }, + { + "name": "/EventEmitter.moon", + "url": "@{fileBaseUrl}", + "sha1": "033A72C3AD897FBABAB96F198B45BF06C708A01F" + }, + { + "name": "/FileCache.moon", + "url": "@{fileBaseUrl}", + "sha1": "00559D640E7B9423CB573B263E8F70F511ABA32D" + }, + { + "name": "/FeedInventory.moon", + "url": "@{fileBaseUrl}", + "sha1": "76544084EC7F2BC8AD9EF5E184354DB98D7520E0" + }, + { + "name": "/FeedManager.moon", + "url": "@{fileBaseUrl}", + "sha1": "2ED36C4231B3A309819646E266F67FC87A6E2AEB" + }, + { + "name": "/FeedTrust.moon", + "url": "@{fileBaseUrl}", + "sha1": "97E31E2295DBEDC430B6952C410509C5EFF1B118" + }, + { + "name": "/FileLock.moon", + "url": "@{fileBaseUrl}", + "sha1": "957BACB8B05540A11E74A255A13529A20A131D73" }, { "name": "/FileOps.moon", - "url": "@{fileBaseUrl}@{fileName}", - "sha1": "D999D34DB93BA76EF0E991CEB1CD63F5CC5F8E68" + "url": "@{fileBaseUrl}", + "sha1": "12EAA710F8688A45E35998C524A30C9AEC1CAF9D" + }, + { + "name": "/GitRepository.moon", + "url": "@{fileBaseUrl}", + "sha1": "133A26FEA5539C40F1E32CD93DA11837BE5A0001" + }, + { + "name": "/Host.moon", + "url": "@{fileBaseUrl}", + "sha1": "2F94704962A6959C6F853B3D29FEDC490555E375" + }, + { + "name": "/JsonSchema.moon", + "url": "@{fileBaseUrl}", + "sha1": "63DAA517D0D4A6622CF995DC5B72FFD2656BEB1E" + }, + { + "name": "/Lock.moon", + "url": "@{fileBaseUrl}", + "sha1": "5DF9CED0D2928DF0B8D6AD5A5CBB006FE5516C0E" }, { "name": "/Logger.moon", - "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1E479FE95F0DFBEE8B098302AB589F32D0C40A00" + "url": "@{fileBaseUrl}", + "sha1": "E3497AC7BEA973FD1E88E50B0B2BEBB491392AB2" + }, + { + "name": "/ModuleLoader.moon", + "url": "@{fileBaseUrl}", + "sha1": "EA1A11F160D1F4F5964BC71E9F2764C3B5091FBE" + }, + { + "name": "/ModuleProvider.moon", + "url": "@{fileBaseUrl}", + "sha1": "07DC7C3364B68B78D0F9709A2639469AA05BF902" + }, + { + "name": "/NamedSemaphore.moon", + "url": "@{fileBaseUrl}", + "sha1": "947E15EE629BAF5F0EAA34BC42ED237F62F9D322" + }, + { + "name": "/Record.moon", + "url": "@{fileBaseUrl}", + "sha1": "D0DDF59DFEAE88CD1497A918E9BB01CD62E550C5" + }, + { + "name": "/ScriptTargetFilter.moon", + "url": "@{fileBaseUrl}", + "sha1": "4D7EF7B000B49C72A90A442EFF8BC0A40A83CA64" + }, + { + "name": "/ScriptUpdateRecord.moon", + "url": "@{fileBaseUrl}", + "sha1": "010977C8F8D5A6C4CB725AADC1DC27DFB08A2022" + }, + { + "name": "/SemanticVersion.moon", + "url": "@{fileBaseUrl}", + "sha1": "CE31F756EEB6B552B07273D82662BBEE5FE37C0A" + }, + { + "name": "/Stub.moon", + "url": "@{fileBaseUrl}", + "sha1": "AD4653665E0806AE1C3E207A80997DE9BED5B421" + }, + { + "name": "/BadMutex.moon", + "url": "@{fileBaseUrl}", + "sha1": "6B1C4E43BB588DDD2DE0FF730F3F288C641C535E", + "delete": true + }, + { + "name": "/Timer.moon", + "url": "@{fileBaseUrl}", + "sha1": "73D7D5C0B9D6CDC5623C47C4DA18B45ED823CB8F" }, { "name": "/UnitTestSuite.moon", - "url": "@{fileBaseUrl}@{fileName}", - "sha1": "ADAB6EFB05E08A7828DCA01BC1FC43D6482979A1" + "url": "@{fileBaseUrl}", + "sha1": "E6F37750CCCEAF6290DA09C55CEE219D6599F953" }, { "name": "/UpdateFeed.moon", - "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1EE16D9D551FF82C2D7E448F2CD980E528874108" + "url": "@{fileBaseUrl}", + "sha1": "CED1C3429CC75FBBBDFB9A6251A03B8AE8503234" + }, + { + "name": "/UpdateTask.moon", + "url": "@{fileBaseUrl}", + "sha1": "D3AD5F032F73B85A02E822E1A6CBEFB32D511819" }, { "name": "/Updater.moon", - "url": "@{fileBaseUrl}@{fileName}", - "sha1": "A4AE061724E68B2EFBB7495A477263E1746E228A" - } - ], - "requiredModules": [ + "url": "@{fileBaseUrl}", + "sha1": "FD4E33AE675284CBD30F05776F32C0008CB73C81" + }, + { + "name": "/ZipArchiver.moon", + "url": "@{fileBaseUrl}", + "sha1": "70D9E8C5B72FA218D11935009788186A5CB73937" + }, + { + "name": "/helpers/ffi-posix.moon", + "url": "@{fileBaseUrl}", + "sha1": "972CE60ABA29D4BE64031C8E0FD087CA8EC59966" + }, + { + "name": "/helpers/ffi-windows.moon", + "url": "@{fileBaseUrl}", + "sha1": "BF0B05684EF8B96B6657C60C4AE6B057A8CBDC55" + }, + { + "name": "/helpers/open-url.moon", + "url": "@{fileBaseUrl}", + "sha1": "7F8313A28BF027859DA51871CADB384261D63F03" + }, + { + "name": "/helpers/resolve-host.moon", + "url": "@{fileBaseUrl}", + "sha1": "7A76C6D24EE45467FD16206DE2880AED4C64231B" + }, + { + "name": "/shims/BadMutex.moon", + "url": "@{fileBaseUrl}", + "sha1": "E492F2D2BFBBDDB66498C6ED8988573279886014" + }, + { + "name": "/shims/DownloadManager.moon", + "url": "@{fileBaseUrl}", + "sha1": "D34BD0B96AEDCC7462C594C193655545A1B62E90" + }, + { + "name": "/shims/PreciseTimer.moon", + "url": "@{fileBaseUrl}", + "sha1": "F976BF883F90623D4D71191F1A5E72703A8551BE" + }, + { + "name": "/Accessors.moon", + "url": "@{fileBaseUrl}", + "sha1": "9C6D9B9C7E6F246D853B7D9EA1E7B108AF419DE5" + }, + { + "name": "/FeedLoader.moon", + "url": "@{fileBaseUrl}", + "sha1": "AA53AF139648ADDB4767207098B92877DD61119C" + }, + { + "name": "/config-schema.moon", + "url": "@{fileBaseUrl}", + "sha1": "503B592CE2E35ACDCEBC65EE135D6D987C8F7EA6" + }, + { + "name": "/Finalizer.moon", + "url": "@{fileBaseUrl}", + "sha1": "3E061756BFF7955E705EBD69E1B46F06E5E5F787" + }, + { + "name": ".moon", + "url": "@{fileBaseUrl}", + "sha1": "2DFD8FDF75573B8C7A91691D5F942B9FF975CA90", + "type": "test" + }, + { + "name": "/BadMutex.moon", + "url": "@{fileBaseUrl}", + "sha1": "BDEA61A6DD65512495FD919754E8E14D351A48EE", + "type": "test" + }, + { + "name": "/Common.moon", + "url": "@{fileBaseUrl}", + "sha1": "13A19DB1AA485B34FEFD692C58CEC6F2705012C0", + "type": "test" + }, + { + "name": "/ConfigHandler.moon", + "url": "@{fileBaseUrl}", + "sha1": "D8B410FA8234F1F2281669FF5DA76AEFAA5FDB35", + "type": "test" + }, + { + "name": "/ConfigView.moon", + "url": "@{fileBaseUrl}", + "sha1": "87483D512F2232D69A9BF82AD57D2DBACFA1450E", + "type": "test" + }, + { + "name": "/Crypto.moon", + "url": "@{fileBaseUrl}", + "sha1": "6E17511ED227B9423E331E7121A037828EB9EF18", + "type": "test" + }, + { + "name": "/Downloader.moon", + "url": "@{fileBaseUrl}", + "sha1": "1FDFF6E1AF4D723744C5B2CF1F8DC33BEA6A6990", + "type": "test" + }, + { + "name": "/Enum.moon", + "url": "@{fileBaseUrl}", + "sha1": "1BF9FF97C5668EB5046EE80B24F52F9C53F54A02", + "type": "test" + }, + { + "name": "/FileCache.moon", + "url": "@{fileBaseUrl}", + "sha1": "908F7ED9C709531CE26DD4B54E97ACCAE441C798", + "type": "test" + }, + { + "name": "/FeedInventory.moon", + "url": "@{fileBaseUrl}", + "sha1": "5CC534E23995ABEAE40D72864685DF7B0703DCD5", + "type": "test" + }, + { + "name": "/FeedManager.moon", + "url": "@{fileBaseUrl}", + "sha1": "3CB2C7274069AD3B43BF4710B4F9793A1B569E8F", + "type": "test" + }, + { + "name": "/FeedTrust.moon", + "url": "@{fileBaseUrl}", + "sha1": "57A6E31DC46854193252067E8A0C3249A8B1F32F", + "type": "test" + }, + { + "name": "/ffi-posix.moon", + "url": "@{fileBaseUrl}", + "sha1": "8EFC18D174558ED7AC6F95DA97602D840F963542", + "type": "test" + }, + { + "name": "/FileLock.moon", + "url": "@{fileBaseUrl}", + "sha1": "FDC65E7FD7EB3190C2F7B888A6D48DD6CE3C1407", + "type": "test" + }, + { + "name": "/FileOps.moon", + "url": "@{fileBaseUrl}", + "sha1": "883E8AC0C67E1335584AFA63D0C899EAB7DF0398", + "type": "test" + }, + { + "name": "/GitRepository.moon", + "url": "@{fileBaseUrl}", + "sha1": "F7E43D74EF6574408DFD1B851EF40F18BDB64FFA", + "type": "test" + }, + { + "name": "/Host.moon", + "url": "@{fileBaseUrl}", + "sha1": "9CFC925DBFE62A67B8F884AD82F89E16AB7C14D7", + "type": "test" + }, + { + "name": "/JsonSchema.moon", + "url": "@{fileBaseUrl}", + "sha1": "FF601104CC6FE6F79EBCD396E7A7161C04978C02", + "type": "test" + }, + { + "name": "/Lock.moon", + "url": "@{fileBaseUrl}", + "sha1": "1E9F6E1CCEACB8A8D2698E0766109264E1046A2D", + "type": "test" + }, + { + "name": "/Logger.moon", + "url": "@{fileBaseUrl}", + "sha1": "1A5D6756191762D312DED41115B4D12658B92F60", + "type": "test" + }, + { + "name": "/ModuleLoader.moon", + "url": "@{fileBaseUrl}", + "sha1": "3BD90881DB08DACDB2F88304F66DE3C27DD9D742", + "type": "test" + }, + { + "name": "/ModuleProvider.moon", + "url": "@{fileBaseUrl}", + "sha1": "493E13DF7E008D78DDD93C3E293401034C4652E2", + "type": "test" + }, + { + "name": "/NamedSemaphore.moon", + "url": "@{fileBaseUrl}", + "sha1": "2D773892B9DDB3B58B0EA4A2E0BED2FED8CA5330", + "type": "test" + }, + { + "name": "/open-url.moon", + "url": "@{fileBaseUrl}", + "sha1": "A6ADC29FF30AB670AB2D0D8ED2A18740B591CD66", + "type": "test" + }, + { + "name": "/Record.moon", + "url": "@{fileBaseUrl}", + "sha1": "1DEB899489F22DE7E5309D7EB23E1F17BEA1EBB5", + "type": "test" + }, + { + "name": "/ScriptTargetFilter.moon", + "url": "@{fileBaseUrl}", + "sha1": "278632008FE11ADB967B4761A2BFD1AB344C12B8", + "type": "test" + }, + { + "name": "/ScriptUpdateRecord.moon", + "url": "@{fileBaseUrl}", + "sha1": "2126448AD1EC7E47736AAE52BB974242C4A84991", + "type": "test" + }, + { + "name": "/SemanticVersion.moon", + "url": "@{fileBaseUrl}", + "sha1": "5350264412F0E191B830D5B607462D0B1D9A79FB", + "type": "test" + }, + { + "name": "/Timer.moon", + "url": "@{fileBaseUrl}", + "sha1": "9666CC4536FE09F6382EEDC5C2580280F7912111", + "type": "test" + }, + { + "name": "/UnitTestSuite.moon", + "url": "@{fileBaseUrl}", + "sha1": "637B340273487957B5EF1ECEA69087F0BD1FF1F2", + "type": "test" + }, + { + "name": "/UpdateFeed.moon", + "url": "@{fileBaseUrl}", + "sha1": "87D26EAD7E574E90C48CA751A6E32D113C2EC8D0", + "type": "test" + }, + { + "name": "/UpdateTask.moon", + "url": "@{fileBaseUrl}", + "sha1": "A0B2306AAC5D244CAD7F9FE32A61ECC0B12B16BE", + "type": "test" + }, + { + "name": "/Updater.moon", + "url": "@{fileBaseUrl}", + "sha1": "4032CDE0DD251DAA247A2E84E922D857966D13B5", + "type": "test" + }, + { + "name": "/ZipArchiver.moon", + "url": "@{fileBaseUrl}", + "sha1": "02A5F4F29EBE807A2F1FCB7BF8264CCB44F70FE5", + "type": "test" + }, + { + "name": "/integration/Downloader.moon", + "url": "@{fileBaseUrl}", + "sha1": "7090AF0D0F2DF5A33392AD7C83E0E596EC37C0D7", + "type": "test" + }, + { + "name": "/integration/ZipArchiver.moon", + "url": "@{fileBaseUrl}", + "sha1": "5E6F0364B0DC35F3A6AAC79C23F35F20AD3EF2AC", + "type": "test" + }, + { + "name": "/helpers/mock-http-server.moon", + "url": "@{fileBaseUrl}", + "sha1": "B95A67E6B631826C1018765E5E925C1144341BC3", + "type": "test" + }, + { + "name": "/helpers/MockHttpServerController.moon", + "url": "@{fileBaseUrl}", + "sha1": "C699B32FF02A9600C0C42DE90402EAB99A8A474A", + "type": "test" + }, + { + "name": "/Accessors.moon", + "url": "@{fileBaseUrl}", + "sha1": "A0ADEAF64D6E7485B605D33B8C787D3ECB0541E9", + "type": "test" + }, + { + "name": "/FeedLoader.moon", + "url": "@{fileBaseUrl}", + "sha1": "C8FB1137C1F75D28F219F4BE13000D36E1A08F82", + "type": "test" + }, { - "moduleName": "requireffi.requireffi", - "version": "0.1.1", - "feed": "@{feed:ffi-experiments}" + "name": "/config-schema.moon", + "url": "@{fileBaseUrl}", + "sha1": "9EFA8FCD1386DF9B35FF82A0952870CEA82527F7", + "type": "test" }, { - "moduleName": "DM.DownloadManager", - "version": "0.3.1", - "feed": "@{feed:ffi-experiments}" + "name": "/Finalizer.moon", + "url": "@{fileBaseUrl}", + "sha1": "10A76A5FC556B3283F1677AD93457C60632A9CD6", + "type": "test" }, { - "moduleName": "BM.BadMutex", - "version": "0.1.3", - "feed": "@{feed:ffi-experiments}" + "name": "/helpers/stub-helpers.moon", + "url": "@{fileBaseUrl}", + "sha1": "531AF23A17A2D6B9D73CA462CF4EDDECE5F4EFE0", + "type": "test" }, { - "moduleName": "PT.PreciseTimer", - "version": "0.1.5", - "feed": "@{feed:ffi-experiments}" + "name": "/integration/Logger.moon", + "url": "@{fileBaseUrl}", + "sha1": "DCA83BB0D8D0E1EB0A08AF3CD670737B341938EB", + "type": "test" + } + ], + "provides": [ + { + "name": "BM.BadMutex", + "version": "^0.1.3" + }, + { + "name": "DM.DownloadManager", + "version": "^0.3.1" + }, + { + "name": "PT.PreciseTimer", + "version": "^0.1.6" } ] } }, "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 +667,219 @@ "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), `SemanticVersion` (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.", + "Common: Added the `makeSet()`, `addDefaults()`, `trim()`, and `escapePattern()` utility helpers (building a set from a list, filling in missing table defaults, stripping surrounding whitespace from a string, and escaping Lua pattern magic characters), shared with the Functional module.", + "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.", + "Automation scripts can now ship a DependencyControl unit test suite, just like modules. `registerMacros` accepts an optional table of test exports (the script's internal values to test), which — together with the script's registered macros, now exposed to the suite by name — is passed to the suite's import function.", + "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.", + "Updater: A required module that no feed offers directly can now be satisfied by installing a module that declares it in `provides` — for example, a script requiring `json` is satisfied by installing `dkjson`. Providers are only used when no feed offers the module by name; a candidate must be installable on your platform and meet the required version, and a directly-named module is always preferred over a provider. Once a provider is installed for a requirement, it keeps being used and updated instead of switching to a different provider (as long as it can still satisfy the requirement). Feeds can now advertise `provides` per release, and `update-feed` mirrors a module's declared aliases into the feed automatically. A provider can pin a provided alias to an npm-style version range (e.g. `~1.2`), so it keeps satisfying requirements within that range without having to be re-published for every patch release of the provided module.", + "Updater: Package sources are now selected by trust to harden against supply-chain attacks. Feeds are consulted closest-first (the feed a script declares for a dependency, then feeds DependencyControl trusts — those in its own feed plus your `extraFeeds` — then any others), and the install is refused if a requirement can only be met from an untrusted feed. Your `extraFeeds` are discovery roots that are also searched for updates, while your `trustedFeeds` only grant trust to feeds reached another way and are never themselves searched. A new `blockedFeeds` config (and an official block list in DependencyControl's own feed) lets a compromised feed be rejected outright. The obsolete `tryAllFeeds` setting and its exhaustive update mode have been removed.", + "Updater: When an install would otherwise be refused because it can only be satisfied from an untrusted feed, DependencyControl can now ask you to confirm and either use it just this once, add it to your trusted feeds, or block it outright; and when several equally-ranked packages can satisfy a requirement it can ask you to pick one. How freely it may prompt is controlled by two new settings: `feedTrustPromptThreshold` for trusting an untrusted feed (defaults to prompting in all situations, since it decides whether an update succeeds) and `packageChoicePromptThreshold` for picking between equally-ranked packages (defaults to only actions you start yourself).", + "Updater: The package picker now also lets you decide how long your choice should stick — use the source only this once, remember it while it stays available, pin it so DependencyControl never switches silently, or hand the decision back to DependencyControl for good. The remembered choice is stored per package and follows the source across feed-URL changes; a pinned source that becomes unavailable fails a required dependency (so you can fix the pin) and skips an optional one. A new global `offerAllSources` setting offers every eligible source (including lower-ranked and untrusted ones) whenever there's more than one, so you can override the ranking.", + "Unit test framework: `assertContains` now honors its case-insensitive option (it previously always compared case-sensitively) and reports a proper message when the assertion fails.", + "Unit test framework: a failing test-class setup is now reported as a failure instead of aborting the entire test run with an internal error.", + "Unit test framework: a failing `assertNegative` now says \"negative\" rather than \"positive\" in its message.", + "Updater: the temporary download directory is now removed after a successful install instead of being left behind.", + "FileOps: `mkdir` no longer reports success when the directory could not actually be created.", + "Logger: the download progress bar now fills incrementally instead of jumping from empty to full at the halfway point.", + "Unit test framework: a failing `assertPositive`/`assertNegative` no longer errors while formatting its failure message.", + "Disabling updates is now fully honored: it also prevents installing or updating modules on demand, not just scheduled background update checks.", + "Updater: Feed and package downloads now refuse hosts that resolve to a private, loopback, or link-local address (an SSRF safeguard), controlled by the new `blockPrivateHosts` setting (on by default; turn it off to use feeds on your local network).", + "Added `FeedInventory` and `FeedManager` — building blocks that list the feeds DependencyControl knows about, each with its provenance and trust status, and apply feed-trust actions. They back the Toolbox's new Manage Feeds macro and are available to other tooling.", + "Feed discovery can now follow feeds' advertised `knownFeeds` transitively to surface feeds beyond the ones you've configured, with untrusted expansion bounded so a hostile feed can't drive unbounded fetches (new `fetchUntrustedFeeds` and `crawlLimits` settings).", + "Fetched feeds are now cached to disk as timestamped snapshots (under `?user/cache/l0.DependencyControl/feeds`), so a recently-fetched feed is served from the cache instead of being re-downloaded, and a failed fetch falls back to the last cached copy for offline resilience. The freshness window is configurable via the new `cacheMaxAge` setting and the location via the shared `cache` path, replacing the former `dumpFeeds` debug dump.", + "DependencyControl's settings file is now organized into topic sections (`updates`, `feeds`, `logging`, `paths`), and an existing config is migrated into the new layout automatically on first load, so no manual edits are needed. Several settings were renamed for consistency in the process (e.g. `updaterEnabled` became `updates.mode`, `updaterBlockPrivateHosts` became `updates.blockPrivateHosts`, and `feedCacheMaxAge` became `feeds.cacheMaxAge`), and the separate feed-cache directory gave way to a single `paths.cache` location that DependencyControl organizes by namespace internally.", + "UpdateFeed: a file marked for deletion in a feed is no longer reported as a missing-source error when deploying, and is now removed from the output directory when present.", + "Updater: The on/off switch grew into an update mode: `updates.mode` takes 'off', 'user-requested', 'dependency-resolution', or 'auto-update' to control which contexts may install and update — for example, disable background update checks while still allowing manual installs and dependency resolution. An existing `updaterEnabled` setting migrates automatically ('auto-update' when true, 'off' when false). The two prompt-threshold settings now take the same context values in place of their former numeric levels, plus 'off' to disable a prompt kind entirely.", + "Unit test framework: Added `ut\\skip(reason)` to skip a single test from within its body — it aborts the test and is reported as skipped (with the reason) instead of passed or failed.", + "Unit test framework: A test class's `_order` now sets only the run and report order; a test omitted from it still runs (appended after the listed ones) instead of being silently skipped. Use `ut\\skip` to skip a test.", + "SemanticVersion can now be used as a version value object: construct it from a version string or from major/minor/patch components, compare instances with the standard operators, read its major/minor/patch, produce bumped copies, and test it against a version range. The static helpers additionally accept an instance anywhere they take a version.", + "Added the Accessors module: a class can declare metatable-backed get/set computed properties through one standardized transform (instead of hand-rolling metatable code), and each property is recorded on the class for inspection tooling. Available to other tooling built on DependencyControl.", + "Record: a record's version is now backed by a SemanticVersion value object, exposed as `record.semanticVersion` for the richer version API; `record.version` continues to return the packed integer as before.", + "The config file now stores each installed script's version as a semantic version string (e.g. \"1.2.3\") instead of a packed integer, so it is readable at a glance; existing configs are migrated automatically on first load.", + "Update feed format v0.4.0: per-file-type URL and local path templates (fileBaseUrls/localFileBasePaths) can be set once at the feed root or on a macros/modules section container and roll down to every package, so file locations no longer need to be repeated per package. The single-string fileBaseUrl/localFileBasePath properties keep working unchanged and act as the fallback for file types without a map entry.", + "Update feed format v0.4.0: feeds can define their own template variables in a root-level vars object, including lookup tables indexed by a computed key (e.g. @{tagSuffix:@{channel}} to derive per-channel release-tag names), and the new @{scriptType}/@{scriptTypeSection} variables expose each package's script type and feed section to templates.", + "The update-feed CLI task can now discover files present on disk but missing from the feed and add complete entries for them (--add-files), by inverting the feed's per-file-type local path templates. A new UpdateFeed.findUnlistedFiles() exposes the discovery to other tooling.", + "The CLI gained a generate-types task that extracts the LuaCATS annotations from a feed's module sources into LuaLS .d.lua type-definition files (for IntelliSense and API-doc generation), powered by the new l0.MoonCats module; its --check flag turns it into an annotation linter that reports missing or mismatched annotations without writing anything.", + "The CLI gained a generate-docs task that renders browsable API documentation from a feed's module sources: one markdown page per module with MoonScript and Lua call forms, typed parameter tables, cross-linked types, and enum documentation, plus ready-to-serve mkdocs or mdBook site scaffolding (--site) and an --include-private switch for contributor-facing builds.", + "Uninstalling a package no longer removes files belonging to a sibling package whose name shares a prefix (e.g. uninstalling l0.Functional could delete a module named l0.FunctionalExtras along with all of its files), and namespaces containing hyphens now match only their own files during uninstall.", + "Fixed installations and updates inside Aegisub failing with 'failed to create temporary download directory ... (unknown error)': Aegisub's bundled lfs signals mkdir/rmdir success differently from stock LuaFileSystem, which the directory-creation error checking misread as a failure.", + "Update and install progress messages no longer read 'Starting nil of ...' for already-installed packages.", + "Logger: the log window no longer repeats the indentation prefix inside incrementally written output such as the download progress bar (log files were unaffected).", + "SemanticVersion: toString no longer errors on nil or SemanticVersion-instance inputs; nil renders as \"0.0.0\".", + "Unit test framework: fixed a cluster of broken assertions — assertItemsEqual/assertItemsAre report a proper failure message and type-check their expected argument, assertContinuous actually checks table keys for continuity (it previously counted integer values), assertNotAlmostEquals reports its own message instead of the assertAlmostEquals text, and assertError no longer over-counts a non-raising function's returned values.", + "Unit test framework: ut\\stub now works inside a class's _setup — setup stubs stay in effect for the whole class including its teardown and are restored when the class finishes. Previously, stubbing in a _setup crashed the test run.", + "Unit test framework: aborting a run on the first failure now records the suite's end time and result, fixing missing timings in the CTRF report.", + "Updater and installer messages read correctly again: 'Couldn't installation module ...' became 'Couldn't complete the installation of module ...', dependency-resolution errors no longer print 'of nil Module', and passing a macro to Updater.require reports its proper error instead of an empty diagnostic.", + "A missing optional dependency is reported by checkOptionalModules again — a failed optional install was mislabeled as updated and never surfaced.", + "A module whose DependencyControl initializer fails now reports the module's name and the actual error; both were previously dropped from the message.", + "Logger: the usePrefix option now applies to both the log file and the log window; the window previously kept its default.", + "Lock: waiting on a lock logs one trace line per acquisition instead of one per poll, unbounded waits no longer print 'Timeout in -9223372036854775808ms', a stale holder is warned about once instead of every 250ms, and tryLock no longer reports wait time it never spent.", + "Updater: skipped-optional-dependency log messages no longer swap the dependency's name with the word 'installation'/'update' (previously e.g. 'Skipped Yutils of optional dependency installation').", + "Updater: when a module can't be installed because one of its own required modules couldn't be satisfied, the error now names the failing sub-requirement and its reason, instead of ending at 'because its requirements could not be satisfied:' with no further detail.", + "The settings file now stores only the values you have actually changed: writing one setting no longer copies the rest of its section's defaults into the file, so default changes in DependencyControl updates take effect without manual config cleanup.", + "UpdateFeed: the feed-loading methods now uniformly return nil plus an error message on failure — fetch previously returned false, and ensureLoaded could return either shape. Code that checks the result for truthiness is unaffected.", + "Scripts using DependencyControl no longer pause for half a second while loading: the wait that spread the random seed apart across simultaneously loading scripts is gone, replaced by a monotonic-clock seed that is unique per script environment on its own." + ] + } + }, + "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/", + "channels": { + "release": { + "version": "2.10.0", + "released": null, + "default": true, + "files": [ + { + "name": ".moon", + "url": "@{fileBaseUrl}", + "sha1": "73EDA9FC07D0F6B4B3713F08262C337B520B69B9" + }, + { + "name": "/vendor/dkjson.lua", + "url": "@{fileBaseUrl}", + "sha1": "A0597E1AEEB14D42DABB3A0E8C05129EB024EDCA" + } + ], + "provides": [ + { + "name": "json" + }, + { + "name": "dkjson" + } + ] + } + }, + "changelog": { + "2.10.0": [ + "Vendored dkjson v2.10 with a DependencyControl version record and json/dkjson self-registration." + ] + } + }, + "l0.MoonCats": { + "name": "MoonCATS", + "description": "Extracts LuaCATS annotations from MoonScript sources into LuaLS type definitions for IntelliSense and API-doc generation.", + "author": "line0", + "url": "@{baseUrl}#@{namespace}", + "channels": { + "release": { + "version": "0.1.0", + "released": null, + "default": true, + "files": [ + { + "name": ".moon", + "url": "@{fileBaseUrl}", + "sha1": "04D24C3341EE9FCD71652F0F936BE830E1768C1C" + }, + { + "name": "/Parser.moon", + "url": "@{fileBaseUrl}", + "sha1": "5EE21217ED0BDFBBD1A18EA46AAA11709428A5F1" + }, + { + "name": "/Emitter.moon", + "url": "@{fileBaseUrl}", + "sha1": "A0965A14BBB23A94205A262FE1AF3EC25C443675" + }, + { + "name": "/Diagnostics.moon", + "url": "@{fileBaseUrl}", + "sha1": "CC9E199C1B1D3994584EFEE58714243BA893FBB5" + }, + { + "name": "/annotations.moon", + "url": "@{fileBaseUrl}", + "sha1": "F7C1277381BF729A09D2EFA8090F2B8B711C0D2A" + }, + { + "name": "/DocRenderer.moon", + "url": "@{fileBaseUrl}", + "sha1": "E6E72F7ECDD4B8982B041C1CD23B43E7625EB478" + }, + { + "name": ".moon", + "url": "@{fileBaseUrl}", + "sha1": "C499B9A977A017B31560211C00A599F443951F99", + "type": "test" + }, + { + "name": "/annotations.moon", + "url": "@{fileBaseUrl}", + "sha1": "B97DA884E5E61C17517C08B53FD8860224E42515", + "type": "test" + }, + { + "name": "/DocRenderer.moon", + "url": "@{fileBaseUrl}", + "sha1": "BED8FE294D2D79B3B31A327CF011EB4EBB06E309", + "type": "test" + }, + { + "name": "/Parser.moon", + "url": "@{fileBaseUrl}", + "sha1": "1E50E0872BEFD7B64180CCC04E07793760F1415C", + "type": "test" + }, + { + "name": "/Emitter.moon", + "url": "@{fileBaseUrl}", + "sha1": "ADC78F3D7B3811775E3A44C19D40EEB07802D853", + "type": "test" + }, + { + "name": "/MoonCats.moon", + "url": "@{fileBaseUrl}", + "sha1": "C39D3DA2C999D8130E8D99ACE7F65E247FF91EDD", + "type": "test" + } + ] + } + }, + "changelog": { + "0.1.0": [ + "Initial release: extracts LuaCATS annotations from MoonScript sources into LuaLS .d.lua type-definition files, with enum-class synthesis, constructor overloads, cross-module type resolution, and an annotation lint mode.", + "Renders API documentation straight from the parsed annotations: one markdown page per module with MoonScript and Lua call forms for every method, constructor parameter tables, cross-linked types, enum member tables with per-value descriptions, deprecation notices, and private members omitted (or badged on request). Emits ready-to-serve mkdocs or mdBook site scaffolding.", + "Class fields, enum exports, and instance defaults are declared as typed @field annotations on the class, and re-exported local functions resolve to their full signature and documentation, so both IntelliSense and generated docs show real types instead of placeholders." ] } } diff --git a/README.md b/README.md index b7d2834..1a0b528 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,146 @@ -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 Updater Feed](#the-updater-feed) +5. [Reference](#reference) +6. [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**: + +- _str_ **updates.mode ["auto-update"]:** Which update contexts may install and update at all. Each value includes the ones before it: + - `"off"` — no installs or updates at all + - `"user-requested"` — only actions you start yourself (e.g. via the Toolbox) + - `"dependency-resolution"` — also installing/updating modules a script depends on + - `"auto-update"` (default) — also background scheduled update checks +- _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. Feeds you list here are treated as trusted. +- _arr_ **trustedFeeds:** additional feed URLs you trust as package sources, on top of the feeds DependencyControl trusts by default (those advertised in its own feed). Unlike `extraFeeds`, these aren't crawled for updates on their own — they only mark a feed as trusted so packages can be installed from it without a warning. +- _arr_ **blockedFeeds:** feed URLs that must never be used as a package source. A blocked feed is rejected regardless of any other setting (including `userFeed`). Applied on top of DependencyControl's own block list. Each entry is matched case-insensitively as a URL prefix, so a host root like `https://example.com/` blocks every feed under it. +- _str_ **feedTrustPromptThreshold ["auto-update"]:** When DependencyControl may ask you to approve installing from a feed not (yet) on the trusted list. Takes the same context values as `updates.mode` (`"off"` never asks; defaults to `"auto-update"`, i.e. asking is allowed in every context). When a situation isn't allowed to prompt, the install fails or is skipped rather than using the untrusted feed. +- _str_ **packageChoicePromptThreshold ["user-requested"]:** When DependencyControl may ask you to pick between equally-ranked packages that can satisfy a requirement. Same context values as above (default `"user-requested"`: only for actions you start yourself). When a situation isn't allowed to prompt, a stable but arbitrary tie-breaker is used. +- _bool_ **packageChoiceOfferAllSources [false]:** By default the package picker only appears when two or more sources are genuinely tied (same trust band and version). Set this to `true` to be offered _every_ eligible source whenever there's more than one (including lower-ranked and untrusted ones). +- _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. +- _obj_ **currentSource [auto]:** Remembers which source last satisfied this package and how sticky that choice is (see [Remembering your source choice](#remembering-your-source-choice)). Managed automatically; don't edit it. +- _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) + +### How DependencyControl Selects Package Sources + +When a script or module needs to be installed or updated, more than one feed may be able to supply it. DependencyControl chooses the source by **trust first, version second**, so an unexpected or compromised feed can't win just by advertising a higher version number. Candidates are ranked best-first: + +1. **The package's own / declared feed**: the feed an installed package advertises, or the feed the depending script declares for the dependency — as long as it still offers the package and remains trusted. +2. **Other trusted feeds**: offering the package directly by name. +3. **Trusted feeds offering a _provider_**: a different module that declares it `provides` the required name (see [Providing module aliases](#providing-module-aliases)). +4. **Untrusted feeds** (by name, then via a provider): interactive installs ask for confirmation before using them. Silent installs/updates are refused until the feed is added to the trusted list. + +Within a tier the highest satisfying version wins. Feeds are fetched lazily in this order, so closer, more-trusted sources are tried before DependencyControl reaches further out. -* *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) +#### Customizing Feed Sources & Trust ------------------------------------------ -### Usage for Automation Scripts ### +The feeds DependencyControl advertises in its own feed are trusted out of the box, as is anything you add yourself. Tune this in your [global configuration](#1-global-configuration): -#### For Macros: #### +- **`trustedFeeds`**: additional feed URLs you trust as package sources. +- **`extraFeeds`**: extra feeds to check for updates (these count as trusted, too). +- **`blockedFeeds`**: feeds that must never be used, overriding everything else (applied in addition to DependencyControl's own block list). Entries match case-insensitively by URL prefix, so a host root blocks every feed under it. +- **`userFeed`** (per script): pin a single feed to be used exclusively for that script. + +(A future DependencyControl Toolbox UI will let you confirm and trust feeds interactively instead of editing the config by hand.) + +#### Remembering your source choice + +When a package can be installed from more than one source and DependencyControl asks you to pick one, the picker also lets you decide how long that choice should stick. The buttons map to: + +- **Just This Once**: use the picked source for this install only; you'll be asked again next time there's a choice. +- **Remember**: keep using the picked source as long as it stays available. If it later disappears (e.g. the feed stops offering the package), DependencyControl asks you to choose again — or, during a non-interactive update, falls back to asking next time and proceeds with its own ranked pick for now. +- **Pin/Lock**: pin this source. DependencyControl will keep using it without asking and will refuse to silently switch — if a pinned source becomes unavailable, a required dependency fails (so you can fix the pin) while an optional one is skipped. +- **Auto-Pick**: stop asking for this package and always take the top-ranked source automatically. +- **Cancel**: cancel the operation without installing. + +A remembered choice follows the source even if its feed URL changes, so moving a feed won't break it. When the picked source is reached through a [provider](#providing-module-aliases), it's the provider that's remembered, so version bumps update it in place instead of switching to a different one. + +## Usage for Automation Scripts + +### For Macros Load DependencyControl at the start of your macro and create a version record. Script and version information is automatically pulled from the `script_*` variables (the additional `script_namespace` variable is **required**). @@ -106,138 +155,186 @@ 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. + +##### Satisfying a dependency with a provider + +`provides` also works across the dependency graph at install time. When a script's `requiredModules` names a module that no feed offers directly, DependencyControl can install a module that lists that name in its `provides` instead — much like Debian's `Provides:` or Arch's `provides`. For example, a script that requires `json` can be satisfied by installing `l0.dkjson`, which provides it. A candidate must actually be installable (its `platforms` must include yours and its version must meet the requirement), a directly-named module is always preferred over a provider, and the usual [package-source precedence](#how-dependencycontrol-selects-package-sources) decides which feed it comes from. Once a provider is installed for a requirement, DependencyControl keeps using and updating that same module rather than switching to a different provider, as long as it can still satisfy the requirement. -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. +A provider can pin _which_ versions of an aliased module it stands in for by giving the table entry an npm-style version range via `provides = {{name = "json", version = "~1.2"}}`. This lets one provider cover a span of releases without being re-published for every patch bump of the aliased module. If not specified, the provider is assumed to satisfy any version requirement for that 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. +For module authors: the `provides` you declare in your version record is mirrored into your feed automatically when you run the `update-feed` CLI, so a published feed advertises it without manual upkeep (you may also add it to a feed by hand). -#### Rules for a valid namespace: #### +#### Advanced: moving a package to a new feed URL - 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) +A package can change the feed it updates from by shipping a release whose record points `feed` at the new URL; DependencyControl picks that up the next time it updates the package. Because source selection is trust-aware (see [How DependencyControl Selects Package Sources](#how-dependencycontrol-selects-package-sources)), +plan migrations with that in mind: -__Examples__: - * l0.ASSFoundation - * l0.ASSFoundation.Common (for a separately version-controlled 'submodule') - * l0.ASSWipe - * a-mo.LineCollection +- If the new URL is **already trusted** (listed in DependencyControl's feed, or added by users), the move is seamless. +- If the new URL is **not yet trusted**, automatic updates to it are held back. To avoid breaking them, keep serving from your **old, already-trusted feed in parallel** during the transition and/or get the new URL added to DependencyControl's trusted list. Individual users can also add it to their own `trustedFeeds`. -#### File and Folder Structure #### +--- -The namespace of your script translates into a subtree of the **user**automation directory you can use to store your files in. DepedencyControl will _not_ refuse to work with scripts that ignore this restriction, however it's designed in such a way that downloading to locations outside of your tree is **impossible** (which means your macro/module be able to use the auto-updater). +## Namespaces and Paths -__Automation Scripts__ use the `?user/automation/autoload`, which has a flat file structure. You may **not** use subdirectories and your **file names must start with the namespace of your script**. +DependencyControl 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: + +1. contains _at least_ one dot +2. must **not** start or end with a dot +3. must **not** contain series of two or more dots +4. the character set is restricted to: `A-Z`, `a-z`, `0-9`, `.`, `_`, `-` +5. _should_ be descriptive (this is more of a guideline) + +**Examples**: + +- `l0.ASSFoundation` +- `l0.ASSFoundation.Common` (for a separately version-controlled 'submodule') +- `l0.ASSWipe` +- `a-mo.LineCollection` + +### File and Folder Structure + +The namespace of your script translates into a subtree of the **user** automation directory you can use to store your files in: + +- On Windows: `%AppData%\Aegisub\automation` +- On Linux: `~/.aegisub/automation` +- On OSX: `~/Library/Application Support/Aegisub/automation` + +DependencyControl will _not outright_ refuse to work with scripts that ignore this restriction, however it's designed in such a way that downloading to locations outside of your tree is **impossible** (which means your package won't be able to use the auto-updater). + +**Automation Scripts** use the `?user/automation/autoload` directory, which has a flat file structure. You may **not** use subdirectories and your **file names must start with the namespace of your script**. Examples: - * l0.ASSWipe.lua - * l0.ASSWipe.Addon.moon -__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` + +**Modules** use the `?user/automation/include` folder, which has a nested file structure. To determine the base name for your main entry point file and sub-directory, the dots in your namespace are replaced with the path separator (`\` on Windows, `/` on other platforms). -__Tests__ use the `?user/automation/tests/DepUnit/modules` or `?user/automation/tests/DepUnit/macros` folder depending on whether a macro or automation is being tested and mirror the directory structure of the respective `include` and `autoload` folders. +**Tests** live under `?user/automation/tests/DepUnit/modules` or `?user/automation/tests/DepUnit/macros`, depending on whether a module or an automation script is being tested. Test files are resolved as `require` identifiers (`DepUnit.modules.` / `DepUnit.macros.`), so the dots in the namespace always become path separators — the tree is **nested** for both modules and macros, even though the `autoload` folder itself is flat. A test suite is a single file at the root of the namespace path; a multi-file suite adds sibling files under a folder of the same name. -Our example module ASSFoundation with namespace __l0.ASSFoundation__ writes (among others) the following files: - * __?user/automation/include/l0/ASSFoundation__.lua - * __?user/automation/include/l0/ASSFoundation__/ClassFactory.lua - * __?user/automation/include/l0/ASSFoundation__/Draw/Bezier.lua - * __?user/automation/tests/modules/l0/ASSFoundation__.lua +Our example module _ASSFoundation_ with namespace `l0.ASSFoundation` writes (among others) the following files: ---------------------------------------------- +- `?user/automation/include/l0/ASSFoundation.lua` +- `?user/automation/include/l0/ASSFoundation/ClassFactory.lua` +- `?user/automation/include/l0/ASSFoundation/Draw/Bezier.lua` +- `?user/automation/tests/DepUnit/modules/l0/ASSFoundation.lua` -### The Anatomy of an Updater Feed ### +An automation script _MyScript_ with namespace `l0.MyScript` lives at `?user/automation/autoload/l0.MyScript.lua` (flat) but its test suite is nested at `?user/automation/tests/DepUnit/macros/l0/MyScript.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", - // The version of the feed format. The current version is 0.3.0, don't touch this until further notice. + "dependencyControlFeedFormatVersion": "0.3.0", + // The version of the feed format. This example uses the simple v0.3.0 layout, which remains fully supported. + // The current version is 0.4.0, which adds optional per-file-type template maps, author-defined + // variables and local path resolution for CI/CLI tooling (see the template section below). "name": "line0's Aegisub Scripts", "description": "Main repository for all of line0's automation macros.", "maintainer": "line0", @@ -246,332 +343,355 @@ 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" + }, + ] } -```` - -#### 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 - -_Depth 3:_ Script Information - 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 - - -__"Rolling" Variables:__ These variables can be defined at any depth in the JSON tree and are continuously expanded using the variables available. You can reference a rolling variable in itself, which will substitute the template for the contents the variable had at the parent-level. - -Right now there's only one such variable: __fileBaseUrl__, which you can use to construct the URL to a file using the template variables available. - -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). - - --------------------------------------------- - -### Reference ### - -This section is currently both incomplete and outdated. Sorry about that. - -#### DependencyControl #### - -__DependencyControl{*tbl* [requiredModules]={}, *str* :name=script_name, *str* :description=script_description, *str* :author=script_author, *str* :url, *str* :version, *str* :moduleName, *str* [:configFile], *string* [:namespace]} --> *obj* DependecyControlRecord__ - -The constructor for a DepedencyControl record. Uses the table-based signature. -__Arguments:__ + }, + "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_: 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). +Full _JSON Schema_ documents (which you can use to validate your feeds) are provided for the following feed versions: -* _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. +- [v0.4.0](./schemas/feed/v0.4.0.json) (current; also validates _v0.3.0_ and legacy _v0.2.0_ feeds) +- [v0.3.0](./schemas/feed/v0.3.0.json) (also validates legacy _v0.2.0_ feeds) -##### Methods ##### -__:checkVersion(*str/num* version, *str* [precision = "patch"]) --> *bool* moduleUpToDate, *str* error__ +### Template Variables -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. +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). -__:checkOptionalModules(*tbl* modules) --> *bool* result, *str* errorMessage__ +**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. -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. +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. -__:getConfigFileName() --> *str* fileName__ +_Depth 1:_ Feed Information -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? +1. `@{feedName}`: The name of the feed +2. `@{baseUrl}`: The baseUrl field +3. `@{feed:}`: A reference to a feed URL in the knownFeeds table -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). +_Depth 2:_ Section Information _(v0.4.0)_ -__:getConfigHandler([defaults], [section], [noLoad]) => *obj* ConfigHandler__ +1. `@{scriptTypeSection}`: the name of the feed section the package lives in (`macros` or `modules`) +2. `@{scriptType}`: the matching script type identifier (`automation` or `module`) -Returns a ConfigHandler (see [ConfigHandler Documentation](#FIXME)) attached to the config file configured for this script. +_Depth 3:_ Script Information -__:getLogger(*tbl* args) => *obj* Logger__ +1. `@{namespace}`: the script namespace +2. `@{namespacePath}`: the script namespace with all `.` replaced by `/` +3. `@{scriptName}`: the script name -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. +_Depth 5:_ Version Information -__:getVersionNumber(*str/num* versionString) --> *int/bool* version, *str* error__ +1. `@{channel}`: the channel name of this version record +2. `@{version}`: the version number as a SemVer string -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. +_Depth 7:_ File Information -__:getVersionString(*int* [version=@version]) --> *str* versionString__ +1. `@{platform}`: the platform defined for this file, otherwise an empty string +2. `@{fileName}`: the file name -Returns a version (by default the script version) as a SemVer string. +**"Rolling" Variables**: These variables can be defined at any depth in the JSON tree — the feed root, a `macros`/`modules` section container _(v0.4.0)_, a package, or a channel — 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. -__:getConfigFileName() --> *str* configFileName__ +1. `@{fileBaseUrl}`: the base URL to construct file download URLs from +2. `@{localFileBasePath}` _(v0.4.0)_: the on-disk counterpart of `@{fileBaseUrl}`, resolved relative to the feed file when a feed is expanded in local mode; it lets CI/CLI tooling (such as the [DepCtrl CLI](#cli)) locate every packaged file's source in your repository -Generates and returns a full path to the registered config file name for the module. +**Per-file-type template maps** _(v0.4.0)_: the `fileBaseUrls` and `localFileBasePaths` properties hold one full URL/path template per file type (`script`, `test`), usually ending in `@{fileName}`. At each file record, the entry matching the file's `type` becomes the effective `@{fileBaseUrl}`/`@{localFileBasePath}` value, so file entries can simply declare `"url": "@{fileBaseUrl}"`. A file type without a map entry falls back to the scalar `fileBaseUrl`/`localFileBasePath`. The maps roll like the scalar variables, so defining them once at the feed root (or on a section container, when your macros and modules trees follow different layouts) describes every package's file locations in one place. -__:loadConfig(*bool* [importRecord], *bool* [forceReloadGlobal]) --> *bool* shouldWriteConfig, *bool* firstInit__ +**Author-defined variables** _(v0.4.0)_: a root-level `vars` object defines your own template variables. A string value is substituted as `@{name}`; an object value is a lookup table indexed as `@{name:key}`, where the key part may itself be a variable. DependencyControl's own feed uses this to derive release-tag names per channel: -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. +```json +"vars": { "tagSuffix": { "alpha": "-alpha", "release": "" } }, +"fileBaseUrls": { "script": "@{fileBaseUrl}v@{version}@{tagSuffix:@{channel}}/@{scriptTypeSection}/@{namespacePath}@{fileName}" } +``` -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._ +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). DependencyControl's [own feed](./DependencyControl.json) exercises the full v0.4.0 feature set: root-level template maps, a section-scoped override for the differently-structured macros tree, and channel-keyed release-tag suffixes. -__:loadModule(*tbl* module, *bool* [usePrivate]) --> *tbl* moduleRef__ +## Reference -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. +The full API reference — every class, method, parameter, and return, for every module — is published at **[typesettingtools.github.io/DependencyControl](https://typesettingtools.github.io/DependencyControl/)**, showing the latest release (with a version picker for earlier releases and the in-development `master`). To build it from your own checkout, or to get the same annotations as editor IntelliSense, see [MoonCATS](#mooncats) below. -__:moveFile(*str* src, *str* dest) --> *bool* success, *str* error__ +### MoonCATS -Moves a file from __source__ to __destiantion__ (where both are full file names). Returns true on success or false and error message on failure. +The `l0.MoonCats` module extracts the [LuaCATS](https://luals.github.io/wiki/annotations/) annotations from MoonScript sources into LuaLS `.d.lua` type definitions and rendered API documentation — see the [`generate-types`](#generate-types--extract-luals-type-definitions) and [`generate-docs`](#generate-docs--render-api-documentation) CLI commands for the packaged workflows. The module itself is a pure text transform available to other tooling: `MoonCats!\extractPackage sources` takes a list of `{requireId, source}` pairs and returns one definition per module plus a diagnostics collection, resolving cross-module references (re-exported classes, package-wide type aliases) across the whole set; `renderDocs sources, opts` runs the same parse and returns documentation pages, an index, and site scaffolding instead; `extractModule` is the single-module convenience. Parsing is AST-assisted via the moonscript rock's own parser, so string literals and comment-lookalike content never confuse the extraction. -__:register(*tbl* selfRef, extraUnitTestArgs...) --> *tbl* selfRef__ +## CLI -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__ +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. -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). +### Prerequisites -Call this method as replacement for returning your module. +- _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: -__:registerMacro(*str* [name=@name], *str* [description=@description], *func* processing_function, *func* [validation_function], *func* is_active_function, *bool|string* [submenu=false])__ + ```sh + luarocks --lua-version=5.1 install moonscript + luarocks --lua-version=5.1 install luafilesystem + luarocks --lua-version=5.1 install argparse + ``` -Alternative Signature: +- Your `LUA_PATH` / `LUA_CPATH` must let `luajit` find the LuaRocks-installed modules (`luarocks --lua-version=5.1 path --bin` prints the correct values). -__:registerMacro(*func* processing_function, *func* [validation_function], *func* is_active_function, *bool|string* [submenu=false])__ +General form: -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. +```sh +luajit depctrl.lua [options] +``` -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. +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. -For the other arguments, please refer to the [aegisub.register_macro](http://docs.aegisub.org/latest/Automation/Lua/Registration/#aegisub.register_macro) API documentation. +### `test` — Run unit test suites -__:registerMacros(*tbl* macros, *bool|string* [submenuDefault=true])__ +```sh +luajit depctrl.lua test [--feed ] [--report-dir ] + [--target-module ] [--target-macro ] +``` -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. +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. -__:registerTests(unitTestArgs...)__ +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. -Registers unit tests for automation modules, passing in any of specified __unitTestArgs__. Registration of modules is done automatically upon calling __:register__ +The feed must have correct `localFileBasePaths` (or `localFileBasePath`) entries so the CLI can resolve source files on disk. -__:requireModules([modules=@requiredModules], *bool* [forceUpdate], *bool* [updateMode], *tbl* [addFeeds={@feed})] --> ...__ +| 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 | -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. +### `bundle` — Build a release archive -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__. +```sh +luajit depctrl.lua bundle [--feed ] [--out-dir ] + [--target-module ] [--target-macro ] +``` -Use __forceUpdate__ to override update intervals and perform update checks for all required modules, even if requirements are satisfied. +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. -__:writeConfig(*bool* [writeLocal=true], *bool* [writeGlobal=true], *bool* [concert]]__ +| 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 | -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._ +Exit code `0` = success, `1` = one or more errors. -#### Updater ##### +### `deploy` — Deploy to a local Aegisub installation -##### Methods ##### +```sh +luajit depctrl.lua deploy [--feed ] [--out-dir ] [--clobber | --no-clobber] + [--target-module ] [--target-macro ] +``` -__:getUpdaterErrorMsg(*int* [code], *str* targetName, ...) --> *str* errorMsg__ +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. -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. +| 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 | -VarArgs: +Exit code `0` = success, `1` = one or more errors. - 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 +### `update-feed` — Refresh and extend the feed -__:getUpdaterLock(*bool* [doWait], *int* [waitTimeout=(user config)]) --> *bool* result, *str* runningHost__ +```sh +luajit depctrl.lua update-feed [--feed ] [--channel ] [--dry-run] [--add-files] + [--target-module ] [--target-macro ] +``` -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. +Refreshes each targeted package's channel in place: recomputes SHA-1 hashes from the local source files, updates the version, dependencies and provided aliases from the package's DependencyControl record, and flags files that have vanished locally with `delete: true`. Any package that changed has its `released` date reset to `null` to mark the build as pending. The feed is validated against the bundled schema before processing. -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_. +With `--add-files`, files found on disk that the targeted channel doesn't list are added to it, complete with computed SHA-1 hashes. Discovery works by inverting the effective per-file-type `localFileBasePaths` templates — every template whose only unexpanded variable is `@{fileName}` is matched against the files below it — so it requires the feed to declare `localFileBasePaths` (at any level; see the template section above). New _packages_ are not discovered; add those to the feed by hand. -__:releaseUpdaterLock()__ +| Option | Default | Description | +| ----------------- | ------------------------------- | ------------------------------------------------------- | +| `--feed` | `DependencyControl.json` in CWD | Path to the feed JSON file | +| `--channel` | _(each package's default)_ | Channel to update | +| `--dry-run` | false | Print what would change without writing back | +| `--add-files` | false | Add entries for on-disk files missing from the channel | +| `--target-module` | _(all modules)_ | Restrict to this module namespace; repeatable | +| `--target-macro` | _(all macros)_ | Restrict to this macro namespace; repeatable | -Makes an updater host (macro) release its lock on the Updater if it has one. See _:getUpdaterLock_ for more information +Exit code `0` = success, `1` = one or more packages had errors. -__:update(*bool* [force], *tbl* [addFeeds], *bool* [tryAllFeeds=auto]) --> *int* resultCode, *str* extError__ +### `generate-types` — Extract LuaLS type definitions -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. +```sh +luajit depctrl.lua generate-types [--feed ] [--out-dir ] [--check] [--target-module ] +``` -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__. +Extracts the [LuaCATS](https://luals.github.io/wiki/annotations/) annotations from the feed's module sources into LuaLS `.d.lua` definition files (one per module, mirroring the require path under the output directory), powered by the [MoonCATS](#mooncats) module. Pointing the [Lua language server](https://luals.github.io/)'s `Lua.workspace.library` setting at the definition tree gives any Lua or MoonScript project full IntelliSense — completion, signature help, and type checking — for every DependencyControl-managed module; this repository's own `.vscode/settings.json` wires up `types/` that way. For browsable API documentation rendered from the same annotations, see [`generate-docs`](#generate-docs--render-api-documentation) below. -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. +Beyond copying annotation blocks, the extractor synthesizes what LuaLS can't infer from MoonScript: a constructor call signature (`@overload`) for every class built from its `new` parameters, a typed per-enum class for each exported `Enum` so members complete and type-check, `@field` declarations for data members and instance defaults, and cross-module types for re-exported classes. Macros are skipped — they aren't require-able and have no type definition to generate. -#### Logger #### +With `--check`, nothing is written; instead the extraction's findings act as an annotation linter: missing annotation blocks on public members, `@param` tags that disagree with the real signature in name or count, and explicit value returns without a `@return` are reported as errors and fail the run. Note that a MoonScript function's implicit final-expression return is deliberately not flagged as missing a `@return`, so a clean check is not proof of complete return documentation. -tbd +| Option | Default | Description | +| ----------------- | ------------------------------- | ------------------------------------------------------------ | +| `--feed` | `DependencyControl.json` in CWD | Path to the feed JSON file | +| `--out-dir` | `types` in CWD | Root directory for the generated definition tree | +| `--check` | false | Lint annotations only; write nothing, exit `1` on errors | +| `--target-module` | _(all modules)_ | Restrict to this module namespace; repeatable | -#### ConfigHandler #### +Exit code in generate mode: `0` = success, `1` = a module failed to parse, a definition failed to emit, or a file couldn't be written (annotation lint findings are reported but don't fail generation). In `--check` mode: `1` when any error-severity finding exists. -tbd +### `generate-docs` — Render API documentation -#### FileOps #### +```sh +luajit depctrl.lua generate-docs [--feed ] [--out-dir ] [--site ] [--site-name ] + [--include-private] [--target-module <ns>] +``` -tbd +Renders browsable API documentation straight from the module sources' LuaCATS annotations — one markdown page per module, plus an index grouped by package. Because it reads the annotations themselves (via [MoonCATS](#mooncats)) rather than the generated type definitions, it documents everything the annotations carry, including details that don't survive into definition files or third-party doc generators: constructor parameter tables with descriptions, deprecation notices with their reasons, per-value enum member descriptions from `@alias` variant lines, and MoonScript's real instance-vs-class member distinction. Every method shows its call signature in both MoonScript and Lua form, parameter and field types cross-link to the page that defines them, and private members are omitted unless `--include-private` renders them with a badge. -#### UnitTestSuite #### +The output is generator-agnostic markdown plus ready-to-serve scaffolding for the site generator of your choice: `--site mkdocs` (the default; emits `mkdocs.yml` preconfigured for the Material theme, so `mkdocs serve` in the output directory just works), `--site mdbook` (emits `SUMMARY.md` and `book.toml` for `mdbook serve`), or `--site none` for the bare pages. The docs are a build artifact — the default output directory `build/docs` is gitignored; regenerate at will. -Reference documentation for the UnitTestSuite module is available in the [source code](https://github.com/TypesettingTools/DependencyControl/blob/master/modules/DependencyControl/UnitTestSuite.moon#L760) +Published API docs are versioned on GitHub Pages by the `Docs` workflow (`.github/workflows/docs.yml`), which regenerates the docs and deploys them with [mike](https://github.com/jimporter/mike): every merge to master updates `/master/`, every `v*` tag publishes an immutable `/<tag>/` copy and moves the `latest` alias, and a manual workflow dispatch on any branch publishes a `/<branch>/` preview. The site's root URL redirects to `latest` once the first release exists, and to `/master/` before then, so it is never a dead link. The Material theme's version picker lists all published versions; a stale preview can be dropped with `mike delete <branch> --push` from a docs build directory. Serving requires the repository's Pages source to be set to the `gh-pages` branch (root). -#### UpdateFeed #### +| Option | Default | Description | +| ------------------- | ------------------------------- | -------------------------------------------------------- | +| `--feed` | `DependencyControl.json` in CWD | Path to the feed JSON file | +| `--out-dir` | `build/docs` in CWD | Output directory for the docs tree | +| `--site` | `mkdocs` | Site scaffold: `mkdocs`, `mdbook`, or `none` | +| `--site-name` | `DependencyControl API` | Site title | +| `--include-private` | false | Render private members (badged) instead of omitting them | +| `--target-module` | _(all modules)_ | Restrict to this module namespace; repeatable | -tbd +Exit code `0` = success, `1` = a module failed to parse or a file couldn't be written. 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..8f86dfc --- /dev/null +++ b/depctrl.lua @@ -0,0 +1,677 @@ +#!/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("<ns>"):count("*") + cmd:option("--target-macro", "Macro namespace to operate on (repeatable; default: all)") + :argname("<ns>"):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 validateCmd = parser:command("validate-schema", + "Validate a config or feed JSON file against its DependencyControl JSON schema") +validateCmd:option("-f --file", "JSON file to validate"):argname("<path>") +validateCmd:option("-t --type", "Schema family to validate against: 'config' or 'feed'"):argname("<type>") +validateCmd:option("--schema-version", + "Validate against a specific schema version (e.g. 0.7.0) instead of auto-selecting the best match"):argname("<ver>") + +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("<name>") +updateFeedCmd:flag("-n --dry-run", "Print what would change without writing back") +updateFeedCmd:flag("-a --add-files", + "Discover files on disk that the targeted channel doesn't list and add entries for them") +addTargets(updateFeedCmd) + +local typesCmd = parser:command("generate-types", + "Extract LuaCATS annotations from module sources into LuaLS .d.lua type-definition files") +typesCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json") +typesCmd:option("-o --out-dir", "Root directory for the generated definition tree"):default("types") +typesCmd:flag("--check", + "Lint annotations only: report findings and write nothing; exits nonzero on error findings") +addTargets(typesCmd) + +local docsCmd = parser:command("generate-docs", + "Render API documentation from module sources' LuaCATS annotations") +docsCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json") +docsCmd:option("-o --out-dir", "Output directory for the docs tree"):default("build/docs") +docsCmd:option("--site", "Site scaffold to emit: 'mkdocs', 'mdbook', or 'none'"):default("mkdocs") +docsCmd:option("--site-name", "Site title"):default("DependencyControl API") +docsCmd:flag("--include-private", "Render private members (badged) instead of omitting them") +addTargets(docsCmd) + +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 "") + +if isWindows then + require("l0.DependencyControl.helpers.ffi-windows").setConsoleOutputUTF8() +end + +-- ── 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 constants = require "l0.DependencyControl.Constants" + local globalConfigPath = aegisub.decode_path("?user/config/" .. constants.DEPCTRL_NAMESPACE .. ".json") + FileOps.mkdir(globalConfigPath, true, true) + do + local json = require "l0.dkjson" + local h = assert(io.open(globalConfigPath, "w")) + h:write(json.encode({ config = { updates = { mode = "off" } } })) + 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 + +-- Collects the selected module packages' non-test .moon sources from a feed, keyed by require +-- id, for annotation extraction. Vendored .lua files have no annotations and are skipped; a +-- warning is printed for any unreadable source. +local function collectModuleSources(feed, filter) + local Common = require "l0.DependencyControl.Common" + local FileOps = require "l0.DependencyControl.FileOps" + + local selected = {} + for pkg, scriptType in feed:walkPackages(filter) do + if scriptType == Common.ScriptType.Module then selected[pkg.namespace] = true end + end + + local function leafSuffix(name) + return (name:gsub("%.moon$", ""):gsub("%.lua$", ""):gsub("/", ".")) + end + + local sources, seen = {}, {} + for file, _, pkg in feed:walkFiles() do + local src = file.localFilePath + if selected[pkg.namespace] and src and file.type ~= "test" and file.name:match("%.moon$") then + local requireId = pkg.namespace .. leafSuffix(file.name) + if not seen[requireId] then + seen[requireId] = true + local text, readErr = FileOps.readFile(src) + if text then + sources[#sources + 1] = { requireId = requireId, source = text } + else + io.stderr:write(("! %s: couldn't read source '%s': %s\n"):format(requireId, src, tostring(readErr))) + end + end + end + end + return sources, selected +end + +-- ── Command dispatch ────────────────────────────────────────────────────────── + +-- ─── test ───────────────────────────────────────────────────────────────────── +if args.command == "test" then + -- Resolve every test suite by its source require identifier, "<namespace>.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 + local allFailures = {} -- accumulated across packages for the end-of-run summary + + 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)) + skipped = skipped + 1 + end + 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 + for _, f in ipairs(record.tests.failures) do + f.namespace = ns + allFailures[#allFailures + 1] = f + end + 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 + + if #allFailures > 0 then + io.stdout:write("\n—— Failures ——\n") + for _, f in ipairs(allFailures) do + io.stdout:write(("\n%s > %s > %s [%s]\n"):format( + f.namespace, f.classname, f.name, f.isAssertion and "assertion" or "error")) + local err = tostring(f.error or ""):gsub("%s+$", "") + io.stdout:write(" " .. err:gsub("\n", "\n ") .. "\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 = not args.dry_run, -- false = dry run; true = write back to the feed's own path + addFiles = args.add_files, + }) + + 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 _, added in ipairs(pkg.addedFiles or {}) do + io.stdout:write((" + %s%s\n"):format(added.name, added.type and (" [" .. added.type .. "]") or "")) + end + 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) + +elseif args.command == "validate-schema" then + if args.type ~= "config" and args.type ~= "feed" then + io.stderr:write("--type must be 'config' or 'feed'.\n"); os.exit(2) + end + if not args.file then + io.stderr:write("--file <path> is required.\n"); os.exit(2) + end + local filePath = resolveAbsPath(args.file) + + -- Loads DependencyControl, which registers its provides searcher so the vendored dkjson resolves as + -- 'json' — the module JsonSchema (and hence lua-schema) needs to parse schema files. + setupDepCtrl("validate-schema") + + local FileOps = require "l0.DependencyControl.FileOps" + local json = require "l0.dkjson" + local JsonSchema = require "l0.DependencyControl.JsonSchema" + + local raw, readErr = FileOps.readFile(filePath) + if not raw then + io.stderr:write(("Couldn't read '%s': %s\n"):format(filePath, tostring(readErr))); os.exit(1) + end + local data, _, decErr = json.decode(raw) + if type(data) ~= "table" then + io.stderr:write(("Couldn't parse '%s' as JSON: %s\n"):format(filePath, tostring(decErr or "not a JSON object"))) + os.exit(1) + end + + local schemaDir = table.concat({ launcherDir, "schemas", args.type }, pathSep) + local schemasByVersion, schemasErr = JsonSchema:getSchemasInDirectory(schemaDir) + if not schemasByVersion then + io.stderr:write(tostring(schemasErr) .. "\n"); os.exit(1) + end + + local valid, version, message + if args.schema_version then + local schemaPath = schemasByVersion[args.schema_version] + if not schemaPath then + io.stderr:write(("No %s schema for version '%s' in %s\n"):format(args.type, args.schema_version, schemaDir)) + os.exit(1) + end + version = args.schema_version + valid, message = JsonSchema(schemaPath):validate(data) + else + -- validateAny selects the schema from the file itself: a config by its root `$schema`; a feed carries no + -- `$schema`, so it's given a function that reads the feed's legacy `dependencyControlFeedFormatVersion` + -- field instead. A file with neither falls back through the available schemas, highest version first. + local hint + if args.type == "feed" then + hint = function(feed) return feed.dependencyControlFeedFormatVersion end + end + valid, version, message = JsonSchema:validateAny(data, schemasByVersion, hint) + end + + if valid then + io.stdout:write(("'%s' is valid against the %s schema v%s.\n"):format(filePath, args.type, tostring(version))) + os.exit(0) + else + io.stderr:write(("'%s' failed %s schema validation%s:\n%s\n"):format( + filePath, args.type, version and (" (v" .. version .. ")") or "", tostring(message))) + os.exit(1) + end + +-- ─── generate-types ─────────────────────────────────────────────────────────── +elseif args.command == "generate-types" then + local feedPath = resolveAbsPath(args.feed) + local outDir = resolveAbsPath(args.out_dir) + + setupDepCtrl("generate-types") + + local FileOps = require "l0.DependencyControl.FileOps" + + local feed = loadFeed(feedPath) + local filter = buildFilter(args) + + if #(args.target_macro or {}) > 0 then + io.stderr:write("Note: macros are not require-able and have no type definitions; --target-macro selectors are ignored.\n") + end + + local sources = collectModuleSources(feed, filter) + if #sources == 0 then + io.stderr:write("No module sources matched in feed '" .. feedPath .. "'.\n") + os.exit(1) + end + + local MoonCats = require "l0.MoonCats" + local result = MoonCats():extractPackage(sources) + local diagnostics = result.diagnostics + + local written, writeErrors = 0, 0 + if not args.check then + for _, def in ipairs(result.definitions) do + local outPath = FileOps.getNamespacedPath(outDir, def.requireId, ".d.lua") + FileOps.mkdir(outPath, true, true) + local ok, writeErr = FileOps.writeFile(outPath, def.text, true) + if ok then + written = written + 1 + io.stdout:write((" %-52s -> %s\n"):format(def.requireId, outPath)) + else + writeErrors = writeErrors + 1 + io.stderr:write(("! %s: couldn't write '%s': %s\n"):format(def.requireId, outPath, tostring(writeErr))) + end + end + end + + local report = diagnostics:format() + if #report > 0 then + io.stdout:write("\n" .. report .. "\n") + end + + local counts = diagnostics:getCounts() + local Severity = MoonCats.Diagnostics.Severity + io.stdout:write(("\n%d module(s) processed: %d error(s), %d warning(s), %d note(s)%s\n"):format( + #sources, counts[Severity.Error], counts[Severity.Warning], counts[Severity.Info], + args.check and " — check mode, nothing written" or (", %d definition(s) written to %s"):format(written, outDir))) + + if args.check then + os.exit(diagnostics:hasCheckFailures() and 1 or 0) + end + -- generation only fails on parse/emit-level breakage or write errors; lint gating is --check's job + local hardFailure = writeErrors > 0 + for _, finding in ipairs(diagnostics.findings) do + local FindingCode = MoonCats.Diagnostics.FindingCode + if finding.code == FindingCode.ParseFailure or finding.code == FindingCode.EmitFailure then + hardFailure = true + end + end + os.exit(hardFailure and 1 or 0) + +-- ─── generate-docs ──────────────────────────────────────────────────────────── +elseif args.command == "generate-docs" then + local feedPath = resolveAbsPath(args.feed) + local outDir = resolveAbsPath(args.out_dir) + + if args.site ~= "mkdocs" and args.site ~= "mdbook" and args.site ~= "none" then + io.stderr:write("--site must be 'mkdocs', 'mdbook', or 'none'.\n"); os.exit(2) + end + + setupDepCtrl("generate-docs") + + local Common = require "l0.DependencyControl.Common" + local FileOps = require "l0.DependencyControl.FileOps" + + local feed = loadFeed(feedPath) + local filter = buildFilter(args) + + if #(args.target_macro or {}) > 0 then + io.stderr:write("Note: macros are not require-able and have no API docs; --target-macro selectors are ignored.\n") + end + + local sources, selected = collectModuleSources(feed, filter) + if #sources == 0 then + io.stderr:write("No module sources matched in feed '" .. feedPath .. "'.\n") + os.exit(1) + end + + -- Feed package info groups the index page: name/version/description per namespace, + -- plus the require ids of the modules each package owns. + local packages = {} + for pkg, scriptType in feed:walkPackages(filter) do + if scriptType == Common.ScriptType.Module and selected[pkg.namespace] then + packages[pkg.namespace] = { + name = pkg.name, + description = pkg.description, + version = feed:getModuleVersion(pkg.namespace), + modules = {}, + } + end + end + for _, source in ipairs(sources) do + for namespace, info in pairs(packages) do + if source.requireId == namespace or source.requireId:sub(1, #namespace + 1) == namespace .. "." then + info.modules[#info.modules + 1] = source.requireId + end + end + end + + local MoonCats = require "l0.MoonCats" + local result, diagnostics = MoonCats():renderDocs(sources, { + includePrivate = args.include_private, + siteName = args.site_name, + site = args.site, + packages = packages, + }) + + local written, writeErrors = 0, 0 + local function writePage(page) + local outPath = outDir .. pathSep .. page.path + FileOps.mkdir(outPath, true, true) + local ok, writeErr = FileOps.writeFile(outPath, page.text, true) + if ok then + written = written + 1 + else + writeErrors = writeErrors + 1 + io.stderr:write(("! couldn't write '%s': %s\n"):format(outPath, tostring(writeErr))) + end + end + writePage(result.indexPage) + for _, page in ipairs(result.pages) do writePage(page) end + for _, file in ipairs(result.scaffold) do writePage(file) end + + local report = diagnostics:format() + if #report > 0 then + io.stdout:write(report .. "\n") + end + + local counts = diagnostics:getCounts() + local Severity = MoonCats.Diagnostics.Severity + io.stdout:write(("\n%d module(s) documented: %d page(s) written to %s (%d error(s), %d warning(s))\n"):format( + #sources, written, outDir, counts[Severity.Error], counts[Severity.Warning])) + + local hardFailure = writeErrors > 0 + for _, finding in ipairs(diagnostics.findings) do + if finding.code == MoonCats.Diagnostics.FindingCode.ParseFailure then hardFailure = true end + end + os.exit(hardFailure and 1 or 0) +end diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index d66c5f6..842e1de 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -1,73 +1,129 @@ export script_name = "DependencyControl Toolbox" export script_description = "Provides DependencyControl maintenance and configuration tools." -export script_version = "0.1.3" +export script_version = "0.3.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" +Common = DepCtrl.Common +constants = require "l0.DependencyControl.Constants" +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." userFeed: "When set the updater will use this feed exclusively to update the script in question." } } + manageFeeds: { + scanning: "Fetching all feeds — this may take a moment..." + noFeeds: "DependencyControl doesn't know about any feeds yet." + openFailed: "Couldn't open a browser for %s." + sourcedWarning: "%s %s will change the update source of these installed packages:\n%s\n\nProceed?" + cantBlockBootstrap: "Refusing that block — it would match DependencyControl's own feed and disable all trust." + promptUntrusted: "This untrusted feed is advertised by another feed:\n%s\n\nFetch it to discover the feeds it lists? \"Trust\" remembers it for next time; \"Block\" hides it." + } } -- Shared Functions +FeedAction = DepCtrl.FeedManager.FeedAction + +-- Dialog button labels the code branches on, centralized so a caption can be reworded in one place without +-- touching dispatch logic — and shared with the tests through testExports, so a rename needs no test edits. +buttons = { + apply: "Apply" + close: "Close" + discover: "Fetch/Discover" + extraFeeds: "Extra Feeds" + blockList: "Block List" + help: "Help" + yes: "Yes" + no: "No" + trust: "Trust" + fetchOnce: "Fetch once" + block: "Block" + skip: "Skip" + cancel: "Cancel" +} + +-- Manage Feeds action-dropdown labels per FeedAction, with the reverse lookup that turns a picked label back +-- into its action. Shared with the tests through testExports. +feedActionLabels = { + [FeedAction.Trust]: "Trust" + [FeedAction.Block]: "Block" + [FeedAction.Unblock]: "Unblock" + [FeedAction.Remove]: "Remove" + [FeedAction.OpenBrowser]: "Browser" +} +feedActionByLabel = {label, action for action, label in pairs feedActionLabels} + +---Builds a script picker's display list and its item→record map, sorted case-insensitively by display string. +---@param populate fun(add: fun(item: string, record: any)) Enumerates the rows, calling `add` once per (display string, record) pair. +---@return string[] list Display strings, sorted case-insensitively. +---@return table<string, any> map Each display string mapped to its record. +buildSortedDlgList = (populate) -> + list, map = {}, {} + populate (item, record) -> + list[#list+1] = item + map[item] = record + table.sort list, (a, b) -> a\lower! < b\lower! + return list, map + buildInstalledDlgList = (scriptType, config, isUninstall) -> - list, map, protectedModules = {}, {}, {} + protectedModules = {} if isUninstall protectedModules[mdl.moduleName] = true for mdl in *DepCtrl.version.requiredModules protectedModules[DepCtrl.version.moduleName] = true - for namespace, script in pairs config.c[scriptType] - continue if protectedModules[namespace] - item = "%s v%s%s"\format script.name, depRec\getVersionString(script.version), - script.activeChannel and " [#{script.activeChannel}]" or "" - list[#list+1] = item - table.sort list, (a, b) -> a\lower! < b\lower! - map[item] = script - return list, map + buildSortedDlgList (add) -> + for namespace, script in pairs config.c[scriptType] + continue if protectedModules[namespace] + -- config entries are on-disk data: an orphaned or unmanaged record may lack name/version + item = "%s v%s%s"\format script.name or namespace, DepCtrl.SemanticVersion\toString(script.version) or "?", + script.activeChannel and " [#{script.activeChannel}]" or "" + add item, script getConfig = (section) -> config = DepCtrl.config\getSectionHandler section config.c.macros or= {} if not section or #section == 0 return config -getKnownFeeds = (config) -> - getScriptFeeds = (t) -> [v.userFeed or v.feed for _,v in pairs config.c[t] when v.feed or v.userFeed] - - -- fetch all feeds and look for further known feeds - recurse = (feeds, knownFeeds = {}, feedList = {}) -> - for url in *feeds - feed = DepCtrl.UpdateFeed url - continue if knownFeeds[url] or not feed.data - feedList[#feedList+1], knownFeeds[url] = feed, true - recurse feed\getKnownFeeds!, knownFeeds, feedList - return knownFeeds, feedList - - -- get additional feeds added by the user - knownFeeds, feedList = recurse DepCtrl.config.c.extraFeeds - -- collect feeds from all installed automation scripts and modules - recurse getScriptFeeds("modules"), knownFeeds, feedList - recurse getScriptFeeds("macros"), knownFeeds, feedList - - return feedList +-- Builds a FeedInventory over a merged config view: the installed macros/modules sections come from their +-- own handlers, while the feed lists and fetch policy are read from the live global config. Shared by the +-- install browser's feed discovery and the Manage Feeds macro. +buildFeedInventory = -> + macrosKey = Common.ScriptTypeSection[Common.ScriptType.Automation] + modulesKey = Common.ScriptTypeSection[Common.ScriptType.Module] + getSectionData = (key) -> + view = DepCtrl.config\getSectionHandler key + view and view.c or {} + mergedC = setmetatable {[macrosKey]: getSectionData(macrosKey), [modulesKey]: getSectionData(modulesKey)}, + {__index: DepCtrl.config.c} + DepCtrl.FeedInventory {c: mergedC}, DepCtrl.updater.feedTrust, DepCtrl.updater.feedLoader getScriptListDlg = (macros, modules) -> { @@ -77,12 +133,44 @@ getScriptListDlg = (macros, modules) -> {name: "module", class: "dropdown", x: 1, y: 1, width: 1, height: 1, items: modules, value: "" } } -runUpdaterTask = (scriptData, exhaustive) -> +runUpdaterTask = (scriptData, 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, nil, scriptData.channel, + DepCtrl.Updater.UpdateReason.UserRequested + return task\run! if task + with scriptData + logger\log DepCtrl.Updater.getUpdaterErrorMsg code, .moduleName or .name, + .moduleName and Common.ScriptType.Module or Common.ScriptType.Automation, isInstall, extErr + +-- our feeds all live under raw.githubusercontent.com; abbreviate that host in the UI and expand it back on input +shortenUrl = (url) -> (url\gsub "^https://raw%.githubusercontent%.com/", "ghuc://") +expandUrl = (url) -> (url\gsub "^ghuc://", "https://raw.githubusercontent.com/") + +-- Under fetchUntrustedFeeds = "prompt", a crawl asks before fetching each untrusted feed: Trust remembers +-- the feed, Block hides it, Fetch once follows it this time only, Skip leaves it unfetched. +promptUntrustedFeed = (url, ft) -> + btn = aegisub.dialog.display { + {class: "label", x: 0, y: 0, width: 3, height: 1, label: msgs.manageFeeds.promptUntrusted\format shortenUrl url} + }, {buttons.trust, buttons.fetchOnce, buttons.block, buttons.skip}, {ok: buttons.fetchOnce, cancel: buttons.skip} + switch btn + when buttons.trust + ft\trust url + true + when buttons.fetchOnce then true + when buttons.block + ft\block url + false + else false + +-- Crawls the feed inventory with the untrusted-feed prompter active (so the `prompt` policy asks), scoped +-- so the prompter never leaks into background fetches. Shared by install discovery and Manage Feeds. +crawlWithPrompt = (inventory) -> + feedTrust = DepCtrl.updater.feedTrust + feedTrust\setPrompter promptUntrustedFeed + entries = inventory\crawl! + feedTrust\setPrompter nil + entries -- Macros @@ -90,50 +178,55 @@ install = -> config = getConfig! addAvailableToInstall = (tbl, feed, scriptType) -> - for namespace, data in pairs feed.data[scriptType] - scriptData = feed\getScript namespace, scriptType == "modules", nil, false + scriptTypeConfigAndFeedKeyName = Common.ScriptTypeSection[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 Common.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.SemanticVersion\toPacked 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 == Common.ScriptType.Module and namespace } return tbl buildDlgList = (tbl) -> - list, map = {}, {} - for namespace, channels in pairs tbl - for channel, rec in pairs channels - item = "%s v%s%s"\format rec.name, rec.version, rec.default and "" or " [#{channel}]" - list[#list+1] = item - table.sort list, (a, b) -> a\lower! < b\lower! - map[item] = { :namespace, :channel, feed: rec.feed, name: rec.name, virtual: true, - moduleName: rec.moduleName } - - return list, map - - -- get a list of the highest versions of automation scripts and modules - -- we can install but wich are not yet installed - macros, modules, feeds = {}, {}, getKnownFeeds config - - logger\log msgs.install.scanning, #feeds - for feed in *feeds - macros = addAvailableToInstall macros, feed, "macros" - modules = addAvailableToInstall modules, feed, "modules" - - -- build macro and module lists as well as reverse mappings + buildSortedDlgList (add) -> + for namespace, channels in pairs tbl + for channel, rec in pairs channels + item = "%s v%s%s"\format rec.name, rec.version, rec.default and "" or " [#{channel}]" + add item, { :namespace, :channel, feed: rec.feed, name: rec.name, virtual: true, + moduleName: rec.moduleName } + + -- get the highest versions of automation scripts and modules we can install but don't have yet. + -- FeedInventory crawls the known feeds, which are trust-gated and bounded. The shared feed loader then + -- serves each reachable feed's data from the cache the crawl just populated. + macros, modules = {}, {} + entries = crawlWithPrompt buildFeedInventory! + + logger\log msgs.install.scanning, #entries + for entry in *entries + continue unless entry.fetched + feed = DepCtrl.updater.feedLoader\load entry.url + continue unless feed.data + macros = addAvailableToInstall macros, feed, Common.ScriptType.Automation + modules = addAvailableToInstall modules, feed, Common.ScriptType.Module + moduleList, moduleMap = buildDlgList modules macroList, macroMap = buildDlgList macros btn, res = aegisub.dialog.display getScriptListDlg macroList, moduleList return unless btn - -- create and run the update tasks macro, mdl = macroMap[res.macro], moduleMap[res.module] - runUpdaterTask mdl, false - runUpdaterTask macro, false + runUpdaterTask mdl, true + runUpdaterTask macro, true uninstall = -> doUninstall = (script) -> @@ -158,7 +251,6 @@ uninstall = -> config = getConfig! - -- build macro and module lists as well as reverse mappings moduleList, moduleMap = buildInstalledDlgList "modules", config, true macroList, macroMap = buildInstalledDlgList "macros", config, true @@ -172,26 +264,22 @@ uninstall = -> update = -> config = getConfig! - -- build macro and module lists as well as reverse mappings moduleList, moduleMap = buildInstalledDlgList "modules", config macroList, macroMap = buildInstalledDlgList "macros", config - dlg = getScriptListDlg macroList, moduleList - dlg[5] = {name: "exhaustive", label: "Exhaustive Mode", class: "checkbox", x: 0, y: 2, width: 1, height: 1} - btn, res = aegisub.dialog.display dlg + btn, res = aegisub.dialog.display getScriptListDlg macroList, moduleList return unless btn - -- create and run the update tasks macro, mdl = macroMap[res.macro], moduleMap[res.module] - runUpdaterTask mdl, res.exhaustive - runUpdaterTask macro, res.exhaustive + runUpdaterTask mdl, false + runUpdaterTask macro, false macroConfig = -> config = getConfig "macros" - dlg, i = {}, 1 + dlg, i = {}, 0 for nsp, macro in pairs config.userConfig - dlg[i*5+t-1] = tbl for t, tbl in ipairs { + dlg[i*5+t] = tbl for t, tbl in ipairs { {label: macro.name, class: "label", x: 0, y: i, width: 1, height: 1 }, {label: "Menu Group: ", class: "label", x: 1, y: i, width: 1, height: 1 }, {name: "#{nsp}.customMenu", class: "edit", x: 2, y: i, width: 1, height: 1, @@ -211,11 +299,316 @@ macroConfig = -> elseif v != "" config.c[nsp][prop] = v - config\write! + config\save! + +-- A simple yes/no confirmation dialog. Returns true only when the user chooses Yes. +confirmDialog = (message) -> + btn = aegisub.dialog.display {{class: "label", x: 0, y: 0, width: 1, height: 1, label: message}}, + {buttons.yes, buttons.no}, {ok: buttons.yes, cancel: buttons.no} + btn == buttons.yes + +-- Add/remove the user's extraFeeds: check feeds to drop and/or type a new one, Apply, re-open. +manageExtraFeeds = (feedTrust) -> + while true + feeds = [url for url in *(DepCtrl.config.c.feeds.extraFeeds or {})] + table.sort feeds + dlg = {} + if #feeds == 0 + dlg[#dlg + 1] = {class: "label", x: 0, y: 0, width: 3, height: 1, label: "You have no extra feeds."} + else + dlg[#dlg + 1] = {class: "label", x: 0, y: 0, width: 3, height: 1, label: "Your extra feeds:"} + for i, url in ipairs feeds + dlg[#dlg + 1] = {class: "label", x: 0, y: i, width: 2, height: 1, label: shortenUrl url} + dlg[#dlg + 1] = {class: "checkbox", x: 2, y: i, width: 1, height: 1, name: "remove#{i}", label: "Remove", value: false} + addY = #feeds + 1 + dlg[#dlg + 1] = {class: "label", x: 0, y: addY, width: 1, height: 1, label: "Add feed URL:"} + dlg[#dlg + 1] = {class: "edit", x: 1, y: addY, width: 2, height: 1, name: "newFeed", text: "", hint: "full URL or ghuc:// shorthand"} + + btn, res = aegisub.dialog.display dlg, {buttons.apply, buttons.close}, {ok: buttons.apply, cancel: buttons.close} + break if not btn or btn == buttons.close + + for i = 1, #feeds + feedTrust\removeExtraFeed feeds[i] if res["remove#{i}"] + newFeed = res.newFeed and res.newFeed\match "^%s*(.-)%s*$" + feedTrust\addExtraFeed expandUrl(newFeed) if newFeed and #newFeed > 0 + +-- Add/remove block entries. Official blocks (from DepCtrl's own feed) are read-only; user blocks can be +-- removed. New blocks match by prefix or exact URL, with an optional reason; blocking the bootstrap feed is refused. +manageBlockList = (feedTrust) -> + BlockMatchMode = DepCtrl.FeedTrust.BlockMatchMode + while true + blocks = feedTrust\getBlockedFeeds! + table.sort blocks, (a, b) -> a.url < b.url + dlg = {} + if #blocks == 0 + dlg[#dlg + 1] = {class: "label", x: 0, y: 0, width: 3, height: 1, label: "The block list is currently empty."} + else + dlg[#dlg + 1] = {class: "label", x: 0, y: 0, width: 2, height: 1, label: "Blocked feed"} + dlg[#dlg + 1] = {class: "label", x: 2, y: 0, width: 1, height: 1, label: "Mode"} + dlg[#dlg + 1] = {class: "label", x: 3, y: 0, width: 2, height: 1, label: "Reason"} + for i, entry in ipairs blocks + dlg[#dlg + 1] = {class: "label", x: 0, y: i, width: 2, height: 1, label: shortenUrl entry.url} + dlg[#dlg + 1] = {class: "label", x: 2, y: i, width: 1, height: 1, label: entry.matchMode} + dlg[#dlg + 1] = {class: "label", x: 3, y: i, width: 2, height: 1, label: entry.reason or ""} + if entry.isOfficial + dlg[#dlg + 1] = {class: "label", x: 5, y: i, width: 1, height: 1, label: "(official)"} + else + dlg[#dlg + 1] = {class: "checkbox", x: 5, y: i, width: 1, height: 1, name: "remove#{i}", label: "Remove", value: false} + addY = #blocks + 1 + dlg[#dlg + 1] = {class: "label", x: 0, y: addY, width: 1, height: 1, label: "Add block:"} + dlg[#dlg + 1] = {class: "edit", x: 1, y: addY, width: 2, height: 1, name: "newUrl", text: "", hint: "feed URL/prefix, full or ghuc://"} + dlg[#dlg + 1] = {class: "dropdown", x: 3, y: addY, width: 1, height: 1, name: "newMode", + items: {BlockMatchMode.Prefix, BlockMatchMode.Exact}, value: BlockMatchMode.Prefix} + dlg[#dlg + 1] = {class: "label", x: 0, y: addY + 1, width: 1, height: 1, label: "Reason:"} + dlg[#dlg + 1] = {class: "edit", x: 1, y: addY + 1, width: 4, height: 1, name: "newReason", text: ""} + + btn, res = aegisub.dialog.display dlg, {buttons.apply, buttons.close}, {ok: buttons.apply, cancel: buttons.close} + break if not btn or btn == buttons.close + + for i, entry in ipairs blocks + feedTrust\unblock entry.url if not entry.isOfficial and res["remove#{i}"] + newUrl = res.newUrl and res.newUrl\match "^%s*(.-)%s*$" + if newUrl and #newUrl > 0 + expanded = expandUrl newUrl + candidate = {url: expanded, matchMode: res.newMode} + if DepCtrl.FeedTrust\matchesBlockEntry constants.DEPCTRL_FEED_URL, candidate + logger\log msgs.manageFeeds.cantBlockBootstrap + else + reason = res.newReason and #res.newReason > 0 and res.newReason or nil + feedTrust\block expanded, {matchMode: res.newMode, :reason} + +-- Compact "time since" label for a Unix timestamp, for the Manage Feeds "Fetched" column. Falls back to an +-- em dash when the feed has never been fetched into the cache. +formatAge = (fetchedAt) -> + return "—" unless fetchedAt + delta = os.time! - fetchedAt + return "now" if delta < 60 + return "#{math.floor delta / 60}m" if delta < 3600 + return "#{math.floor delta / 3600}h" if delta < 86400 + return "#{math.floor delta / 86400}d" if delta < 604800 + "#{math.floor delta / 604800}w" + +-- Single source of truth for the Manage Feeds glyphs, shared by the feed-list rendering (`manageFeeds`) and +-- the Help legend (`manageFeedsHelp`). Change a glyph here and both follow. +glyphs = { + -- OK column (reachability) + reachable: "✓" + unreachable: "✗" + unknown: "?" + -- Trust column (Harvey balls: official = left half, user = right half, both = full) + untrusted: "⭘" + trustOfficial: "◐" + trustUser: "◑" + trustBoth: "⬤" + blocked: "⊘" + -- Known column + ownFeed: "★" + officialKnown: "☆" + transitive: "≫" + -- Cust column (your customizations) + extraFeed: "E" + override: "O" + -- Pkg column (a package's own feed refs) + declared: "D" + advertised: "A" + -- Use column + inUse: "↻" +} -depRec\registerMacros{ +-- A read-only reference explaining the feed-list columns and their glyphs, opened from the Help button. +manageFeedsHelp = -> + sections = { + {"OK", "Feed reachability, filled in after fetch: ❬ #{glyphs.reachable} ❭ reachable · ❬ #{glyphs.unreachable} ❭ unreachable · ❬ #{glyphs.unknown} ❭ not checked yet"} + {"Fetched", "How long ago each feed was last fetched into the cache (e.g. 5m, 2h, 3d, 4w); ❬ — ❭ never fetched. Read from the cache, so it's shown before fetch too."} + {"Trust", "How DependencyControl treats the feed: ❬ #{glyphs.untrusted} ❭ untrusted · ❬ #{glyphs.trustOfficial} ❭ officially trusted · ❬ #{glyphs.trustUser} ❭ user-trusted · ❬ #{glyphs.trustBoth} ❭ official + user · ❬ #{glyphs.blocked} ❭ blocked"} + {"Known", "How the feed is known to DependencyControl: ❬ #{glyphs.ownFeed} ❭ its own feed · ❬ #{glyphs.officialKnown} ❭ official (listed in its feed) · ❬ #{glyphs.transitive} ❭ transitive (advertised by another feed) · ❬ #{glyphs.unknown} ❭ not checked yet"} + {"Cust", "Your customizations: ❬ #{glyphs.extraFeed} ❭ a feed in your extraFeeds · ❬ #{glyphs.override} ❭ your per-package feed override"} + {"Pkg", "A package's own feed references: ❬ #{glyphs.declared} ❭ declared by an installed package · ❬ #{glyphs.advertised} ❭ advertised by a package's dependency"} + {"Use", "Whether the feed is the effective update source of an installed package: ❬ #{glyphs.inUse} ❭ yes"} + {"Action", "Trust, Block, Unblock, Remove the feed, or open the feed in the DepCtrl Browser for details."} + } + dlg = { {class: "label", x: 0, y: 0, width: 4, height: 1, label: "Manage Feeds — what each column means"} } + for i, s in ipairs sections + dlg[#dlg + 1] = {class: "label", x: 0, y: i + 1, width: 1, height: 1, label: s[1]} + dlg[#dlg + 1] = {class: "label", x: 1, y: i + 1, width: 7, height: 1, label: s[2]} + aegisub.dialog.display dlg, {buttons.close}, {ok: buttons.close, cancel: buttons.close} + +-- Lets the user see and manage the feeds DependencyControl knows about and their trust status. The reachable +-- feeds are gathered (or crawled) via FeedInventory; FeedManager computes the per-feed actions and executes +-- them through the shared feedTrust. Aegisub's dialog toolkit has no list widget, so this uses the +-- redisplay-loop / row-grid pattern: pick an action per feed, apply, re-open. +manageFeeds = -> + feedTrust = DepCtrl.updater.feedTrust + FeedInventory = DepCtrl.FeedInventory + FeedManager = DepCtrl.FeedManager + TrustStatus = DepCtrl.FeedTrust.TrustStatus + Provenance = FeedInventory.Provenance + openUrl = require "l0.DependencyControl.helpers.open-url" + + inventory = buildFeedInventory! + manager = FeedManager feedTrust + + trustGlyphs = { + [TrustStatus.TrustedOfficial]: glyphs.trustOfficial + [TrustStatus.TrustedUser]: glyphs.trustUser + [TrustStatus.TrustedBoth]: glyphs.trustBoth + [TrustStatus.Untrusted]: glyphs.untrusted + [TrustStatus.Blocked]: glyphs.blocked + } + provenanceLabels = { + [Provenance.OfficialDepCtrl]: "DependencyControl's own feed" + [Provenance.OfficialKnown]: "known to DependencyControl" + [Provenance.UserExtra]: "in your extra feeds" + [Provenance.PackageDeclared]: "declared by an installed package" + [Provenance.PackageOverride]: "an installed package's feed override" + [Provenance.DependencyAdvertised]: "advertised by a package dependency" + [Provenance.TransitiveKnown]: "advertised by another feed" + } + describeProvenance = (row) -> table.concat [provenanceLabels[p] or p for p in *row.provenance], "; " + + -- "Known" folds official and transitive knowledge into one column: DepCtrl's own feed, an officially-known + -- feed listed in its feed, or a transitively-known feed advertised by another feed (shown only when not + -- official). Transitive knowledge isn't determined until a Discover has run, so it reads unknown until then. + getKnownGlyph = (has, row, discovered) -> + return glyphs.ownFeed if has[Provenance.OfficialDepCtrl] + return glyphs.officialKnown if has[Provenance.OfficialKnown] + return glyphs.unknown unless discovered + has[Provenance.TransitiveKnown] and glyphs.transitive or "" + + -- "Cust" (your customizations: extra feed / override) and "Pkg" (a package's own feed refs: declared / + -- advertised) groups can co-occur, so each shows letter marks for whichever are present. Every glyph is + -- called with (has, row, discovered); most use only `has`. + provColumns = { + {header: "Know", glyph: getKnownGlyph} + {header: "Cust", glyph: (has) -> (has[Provenance.UserExtra] and glyphs.extraFeed or "") .. (has[Provenance.PackageOverride] and glyphs.override or "")} + {header: "Pkg", glyph: (has) -> (has[Provenance.PackageDeclared] and glyphs.declared or "") .. (has[Provenance.DependencyAdvertised] and glyphs.advertised or "")} + {header: "Use", glyph: (has, row) -> row.inUse and glyphs.inUse or ""} + } + + -- prompts for an optional block reason; returns the reason (possibly "") or nil if the user cancels + promptBlockReason = (url) -> + btn, res = aegisub.dialog.display { + {class: "label", x: 0, y: 0, width: 2, height: 1, label: "Reason for blocking #{url} (optional):"} + {class: "edit", x: 0, y: 1, width: 2, height: 1, name: "reason", text: ""} + }, {buttons.block, buttons.cancel}, {ok: buttons.block, cancel: buttons.cancel} + return nil unless btn == buttons.block + res.reason + + buildDialog = (rows, discovered) -> + feedCol = 2 -- feed URL sits after the OK (x0) and Trust (x1) columns + feedWidth = 4 + fetchedCol = feedCol + feedWidth -- when each feed was last fetched into the cache + fetchedWidth = 2 + provStart = fetchedCol + fetchedWidth + actionCol = provStart + #provColumns + dlg = { + {class: "label", x: 0, y: 0, width: 1, height: 1, label: "OK"} + {class: "label", x: 1, y: 0, width: 1, height: 1, label: "Trust"} + {class: "label", x: feedCol, y: 0, width: feedWidth, height: 1, label: "Feed"} + {class: "label", x: fetchedCol, y: 0, width: fetchedWidth, height: 1, label: "Fetched"} + {class: "label", x: actionCol, y: 0, width: 1, height: 1, label: "Action"} + } + for j, col in ipairs provColumns + dlg[#dlg + 1] = {class: "label", x: provStart + j - 1, y: 0, width: 1, height: 1, label: col.header} + for i, row in ipairs rows + has = {p, true for p in *row.provenance} + reach = discovered and (row.reachable and glyphs.reachable or glyphs.unreachable) or glyphs.unknown -- unknown until a Discover has fetched + items = {"—"} + items[#items + 1] = feedActionLabels[a] for a in *row.actions + dlg[#dlg + 1] = {class: "label", x: 0, y: i, width: 1, height: 1, label: reach} + dlg[#dlg + 1] = {class: "label", x: 1, y: i, width: 1, height: 1, label: trustGlyphs[row.trustStatus] or glyphs.unknown} + dlg[#dlg + 1] = {class: "label", x: feedCol, y: i, width: feedWidth, height: 1, label: shortenUrl row.url} + dlg[#dlg + 1] = {class: "label", x: fetchedCol, y: i, width: fetchedWidth, height: 1, label: formatAge row.lastFetchedAt} + for j, col in ipairs provColumns + dlg[#dlg + 1] = {class: "label", x: provStart + j - 1, y: i, width: 1, height: 1, label: col.glyph(has, row, discovered)} + dlg[#dlg + 1] = {class: "dropdown", x: actionCol, y: i, width: 1, height: 1, name: "action#{i}", + items: items, value: "—", hint: describeProvenance row} + dlg + + applySelections = (res, rows) -> + for i, row in ipairs rows + label = res["action#{i}"] + continue if not label or label == "—" + action = feedActionByLabel[label] + continue unless action + + if action == FeedAction.OpenBrowser + ok = openUrl.open row.browserUrl + logger\log msgs.manageFeeds.openFailed, row.url unless ok + continue + + if action == FeedAction.Block or action == FeedAction.Remove + sourced = inventory\getPackagesSourcedFrom row.url + if #sourced > 0 + verb = action == FeedAction.Block and "Blocking" or "Removing" + message = msgs.manageFeeds.sourcedWarning\format verb, row.url, table.concat(sourced, "\n") + continue unless confirmDialog message + + opts = {} + if action == FeedAction.Block + reason = promptBlockReason row.url + continue if reason == nil + opts.reason = reason if reason != "" + + manager\applyAction action, row, opts + + discovered = false + while true + logger\log msgs.manageFeeds.scanning if discovered + entries = discovered and crawlWithPrompt(inventory) or inventory\gather! + rows = FeedManager.buildRows entries + if #rows == 0 + logger\log msgs.manageFeeds.noFeeds + return + btn, res = aegisub.dialog.display buildDialog(rows, discovered), + {buttons.apply, buttons.discover, buttons.extraFeeds, buttons.blockList, buttons.help, buttons.close}, + {ok: buttons.apply, cancel: buttons.close} + break if not btn or btn == buttons.close + switch btn + when buttons.discover then discovered = true + when buttons.extraFeeds then manageExtraFeeds feedTrust + when buttons.blockList then manageBlockList feedTrust + when buttons.help then manageFeedsHelp! + else applySelections res, rows + +-- 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 + -- a structured status is a deliberate refusal or an already-reported run result, + -- and its message stands on its own + logger\trace DepCtrl.Updater.getUpdaterErrorMsg errMsgOrErrCode, record.name or record.namespace, record.scriptType, false, errDetail + + if record.tests and record.scriptType == Common.ScriptType.Module + success, errMsg = pcall record.tests\registerMacros + unless success + logger\trace msgs.scheduleUpdatesAndRegisterTests.registerMacrosError, record.name or record.namespace, errMsg + + DepCtrl.updater\releaseLock! + +depRec\registerMacros { {"Install Script", "Installs an automation script or module on your system.", install}, {"Update Script", "Manually check and perform updates to any installed script.", update}, {"Uninstall Script", "Removes an automation script or module from your system.", uninstall}, + {"Manage Feeds", "See and manage the feeds DependencyControl knows about and their trust status.", manageFeeds}, {"Macro Configuration", "Lets you change per-automation script settings.", macroConfig}, -}, "DependencyControl" \ No newline at end of file +}, "DependencyControl", {:shortenUrl, :expandUrl, :formatAge, :buildInstalledDlgList, :promptUntrustedFeed, + :confirmDialog, :manageExtraFeeds, :manageBlockList, :buttons, :feedActionLabels, + :scheduleUpdatesAndRegisterTests} + +-- The startup sweep is an Aegisub-session concern; headless (CLI/test runner) has no session to +-- schedule for, and running it would trigger live update checks while a test require is in flight. +scheduleUpdatesAndRegisterTests! unless Common.isHeadless! diff --git a/macros/l0.DependencyControl.Toolbox/test.moon b/macros/l0.DependencyControl.Toolbox/test.moon new file mode 100644 index 0000000..8a91b1f --- /dev/null +++ b/macros/l0.DependencyControl.Toolbox/test.moon @@ -0,0 +1,554 @@ +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +constants = require "l0.DependencyControl.Constants" +Common = require "l0.DependencyControl.Common" +DepCtrl = require "l0.DependencyControl" + +-- Suite for the DependencyControl Toolbox macro. The Plumbing class proves the automation-script test +-- wiring (DependencyControl discovers the suite and hands it the script's registered macros + testExports); +-- the Urls/Age/InstalledList/UntrustedPrompt classes cover the pure and near-pure helpers; the remaining +-- classes drive the dialog flows end to end, stubbing aegisub.dialog.display and the Toolbox's collaborators +-- (the updater, config handler, feed inventory/manager, and the injected feed trust model). +UnitTestSuite "l0.DependencyControl.Toolbox", (macros, dependencies, testExports, controls) -> + {:shortenUrl, :expandUrl, :formatAge, :buildInstalledDlgList, :promptUntrustedFeed, + :confirmDialog, :manageExtraFeeds, :manageBlockList, :buttons, :feedActionLabels, + :scheduleUpdatesAndRegisterTests} = testExports + + -- The UninstallFlow seam: its _setup swaps the DepCtrl class's __call for a constructor returning + -- `uninstallSeam.record`, so the flow's `DepCtrl(script)\uninstall!` never builds (and registers) a real + -- record. Held at suite scope because tests don't receive their class's setup context. + uninstallSeam = {} + + -- a fake config the way buildInstalledDlgList reads it: config.c[section] is the installed-package map + makeConfig = (section, entries) -> {c: {[section]: entries}} + -- the set of script display names present in a buildInstalledDlgList map + namesIn = (map) -> {v.name, true for _, v in pairs map} + + -- Stub aegisub.dialog.display to return a queued sequence of results, one per call: each entry is a + -- {button, values} pair (or {false} to model a cancel). Exhausting the queue returns nil, which the + -- redisplay loops read as "close". Returns the stub for call assertions. + queueDialog = (ut, responses) -> + i = 0 + ut\stub(aegisub.dialog, "display")\calls (...) -> + i += 1 + r = responses[i] or {} + unpack r, 1, #r + + -- Runs one Uninstall Script pass selecting "X v1.0.0", with the constructed record's uninstall returning + -- the given values; returns how often uninstall ran. Requires the UninstallFlow construction seam. + runUninstall = (ut, ...) -> + results = table.pack ... + ran = 0 + uninstallSeam.record = { + uninstall: => + ran += 1 + unpack results, 1, results.n + } + ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {macros: {"a.x": {name: "X", version: "1.0.0"}}, modules: {}}} + queueDialog ut, {{"OK", {macro: "X v1.0.0", module: ""}}} + macros["Uninstall Script"].process! + ran + + -- a fake FeedTrust recording the mutations the trust panels delegate to it; getBlockedFeeds hands back a + -- fresh copy each call so a panel's in-place sort can't corrupt the source list across redisplay cycles + makeFeedTrust = (blocked) -> + calls = {block: {}, unblock: {}, trust: {}, addExtraFeed: {}, removeExtraFeed: {}} + { + :calls + getBlockedFeeds: => [b for b in *(blocked or {})] + block: (url, opts) => calls.block[#calls.block + 1] = {url, opts} + unblock: (url) => calls.unblock[#calls.unblock + 1] = url + trust: (url) => calls.trust[#calls.trust + 1] = url + addExtraFeed: (url) => calls.addExtraFeed[#calls.addExtraFeed + 1] = url + removeExtraFeed: (url) => calls.removeExtraFeed[#calls.removeExtraFeed + 1] = url + } + + -- a minimal fake UpdateFeed exposing one installable automation script, enough for install discovery + makeFakeFeed = -> + scriptData = { + name: "X" + data: {channels: {main: {version: "1.0.0"}}} + getChannels: => {"main"}, "main" + } + { + url: "feed://x" + data: {macros: {"a.x": true}, modules: {}} + getScript: (ns, st) => scriptData, nil + } + + { + Plumbing: { + _description: "Automation-script test wiring: registered macros and testExports reach the suite." + + -- every macro registered through registerMacros is exposed by name, carrying its unhooked process + receivesRegisteredMacros: (ut) -> + for name in *{"Install Script", "Update Script", "Uninstall Script", "Manage Feeds", "Macro Configuration"} + ut\assertNotNil macros[name] + ut\assertFunction macros[name].process + + -- the script's own internal helpers, passed straight through as testExports + receivesTestExports: (ut) -> + ut\assertNotNil testExports + ut\assertFunction testExports.shortenUrl + ut\assertFunction testExports.expandUrl + } + + Urls: { + _description: "shortenUrl/expandUrl: raw.githubusercontent.com <-> ghuc:// abbreviation." + + -- the raw.githubusercontent.com host collapses to the ghuc:// scheme + shorten_abbreviatesHost: (ut) -> + ut\assertEquals shortenUrl("https://raw.githubusercontent.com/TT/DepCtrl/master/x.json"), + "ghuc://TT/DepCtrl/master/x.json" + + -- a URL on any other host is left untouched + shorten_passesThroughOther: (ut) -> + ut\assertEquals shortenUrl("https://example.com/feed.json"), "https://example.com/feed.json" + + -- ghuc:// expands back to the full raw.githubusercontent.com URL + expand_restoresHost: (ut) -> + ut\assertEquals expandUrl("ghuc://TT/DepCtrl/master/x.json"), + "https://raw.githubusercontent.com/TT/DepCtrl/master/x.json" + + -- shorten then expand round-trips a raw.githubusercontent.com URL unchanged + roundTrips: (ut) -> + url = "https://raw.githubusercontent.com/TT/DepCtrl/master/x.json" + ut\assertEquals expandUrl(shortenUrl url), url + + _order: {"shorten_abbreviatesHost", "shorten_passesThroughOther", "expand_restoresHost", "roundTrips"} + } + + Age: { + _description: "formatAge: compact 'time since' label for a feed's last-fetch timestamp." + + -- an absent timestamp (never fetched) renders as an em dash + never_isDash: (ut) -> ut\assertEquals formatAge(nil), "—" + + -- under a minute reads as 'now'; larger spans round down into the coarsest unit that fits + picksCoarsestUnit: (ut) -> + now = os.time! + ut\assertEquals formatAge(now), "now" + ut\assertEquals formatAge(now - 120), "2m" + ut\assertEquals formatAge(now - 7200), "2h" + ut\assertEquals formatAge(now - 172800), "2d" + ut\assertEquals formatAge(now - 1209600), "2w" + } + + InstalledList: { + _description: "buildInstalledDlgList: installed-package picker rows, with the uninstall protected-module guard." + + -- uninstall must not offer DependencyControl's own stack (its namespace is protected) + uninstall_excludesProtected: (ut) -> + config = makeConfig "modules", { + [constants.DEPCTRL_NAMESPACE]: {name: "DepCtrl", version: "0.7.0"} + "l0.Other": {name: "Other", version: "1.2.3"} + } + list, map = buildInstalledDlgList "modules", config, true + names = namesIn map + ut\assertNil names["DepCtrl"] -- protected on uninstall + ut\assertTrue names["Other"] + ut\assertEquals #list, 1 + + -- install/update (not an uninstall) protects nothing, so every installed package is offered + install_includesAll: (ut) -> + config = makeConfig "modules", { + [constants.DEPCTRL_NAMESPACE]: {name: "DepCtrl", version: "0.7.0"} + "l0.Other": {name: "Other", version: "1.2.3"} + } + list, map = buildInstalledDlgList "modules", config, false + names = namesIn map + ut\assertTrue names["DepCtrl"] + ut\assertTrue names["Other"] + ut\assertEquals #list, 2 + + -- rows are sorted case-insensitively by display name + sortsByNameCaseInsensitively: (ut) -> + config = makeConfig "macros", { + "a.z": {name: "Zebra", version: "1.0.0"} + "a.a": {name: "apple", version: "1.0.0"} + "a.m": {name: "Mango", version: "1.0.0"} + } + list = buildInstalledDlgList "macros", config, false + ut\assertEquals list, {"apple v1.0.0", "Mango v1.0.0", "Zebra v1.0.0"} + + -- a package with a non-default active channel shows the channel in brackets + formatsActiveChannel: (ut) -> + config = makeConfig "macros", {"a.x": {name: "X", version: "2.0.0", activeChannel: "beta"}} + list = buildInstalledDlgList "macros", config, false + ut\assertEquals list[1], "X v2.0.0 [beta]" + + -- an entry lacking name/version (e.g. an orphaned unmanaged record) still gets a row + toleratesMissingVersionAndName: (ut) -> + config = makeConfig "modules", {"a.orphan": {}} + list = buildInstalledDlgList "modules", config, false + ut\assertEquals list[1], "a.orphan v0.0.0" + + _order: {"uninstall_excludesProtected", "install_includesAll", "sortsByNameCaseInsensitively", "formatsActiveChannel", "toleratesMissingVersionAndName"} + } + + UntrustedPrompt: { + _description: "promptUntrustedFeed: maps the untrusted-feed dialog choice to a fetch decision + trust side effect." + + -- Trust: remembers the feed and fetches it + trust_remembersAndFetches: (ut) -> + ft = makeFeedTrust! + ut\stub(aegisub.dialog, "display")\calls -> buttons.trust + ut\assertTrue promptUntrustedFeed "feed://x", ft + ut\assertEquals ft.calls.trust, {"feed://x"} + ut\assertEquals #ft.calls.block, 0 + + -- Fetch once: fetches without remembering + fetchOnce_fetchesWithoutTrusting: (ut) -> + ft = makeFeedTrust! + ut\stub(aegisub.dialog, "display")\calls -> buttons.fetchOnce + ut\assertTrue promptUntrustedFeed "feed://x", ft + ut\assertEquals #ft.calls.trust, 0 + ut\assertEquals #ft.calls.block, 0 + + -- Block: blocks the feed and refuses the fetch + block_blocksAndRefuses: (ut) -> + ft = makeFeedTrust! + ut\stub(aegisub.dialog, "display")\calls -> buttons.block + ut\assertFalse promptUntrustedFeed "feed://x", ft + ut\assertEquals ft.calls.block[1][1], "feed://x" + ut\assertEquals #ft.calls.trust, 0 + + -- Skip (and dialog cancel): refuses the fetch, changing no trust state + skip_refusesQuietly: (ut) -> + ft = makeFeedTrust! + ut\stub(aegisub.dialog, "display")\calls -> buttons.skip + ut\assertFalse promptUntrustedFeed "feed://x", ft + ut\assertEquals #ft.calls.trust, 0 + ut\assertEquals #ft.calls.block, 0 + + _order: {"trust_remembersAndFetches", "fetchOnce_fetchesWithoutTrusting", "block_blocksAndRefuses", "skip_refusesQuietly"} + } + + Confirm: { + _description: "confirmDialog: a yes/no gate that is true only on Yes." + + yes_isTrue: (ut) -> + ut\stub(aegisub.dialog, "display")\calls -> buttons.yes + ut\assertTrue confirmDialog "proceed?" + + no_isFalse: (ut) -> + ut\stub(aegisub.dialog, "display")\calls -> buttons.no + ut\assertFalse confirmDialog "proceed?" + + _order: {"yes_isTrue", "no_isFalse"} + } + + BlockList: { + _description: "manageBlockList: the block-list panel — remove user blocks, add a block, refuse blocking the bootstrap feed." + + -- Close on the first display makes no changes + close_makesNoChanges: (ut) -> + ft = makeFeedTrust {{url: "feed://x", isOfficial: false, matchMode: DepCtrl.FeedTrust.BlockMatchMode.Prefix}} + queueDialog ut, {{buttons.close}} + manageBlockList ft + ut\assertEquals #ft.calls.unblock, 0 + ut\assertEquals #ft.calls.block, 0 + + -- a checked user block is unblocked; a checked official block is left alone (it has no remove control) + removesUserBlockNotOfficial: (ut) -> + ft = makeFeedTrust { + {url: "feed://official", isOfficial: true, matchMode: DepCtrl.FeedTrust.BlockMatchMode.Prefix} + {url: "feed://user", isOfficial: false, matchMode: DepCtrl.FeedTrust.BlockMatchMode.Prefix} + } + -- sorted by URL: [feed://official, feed://user] -> remove1 targets the official (ignored), remove2 the user + queueDialog ut, {{buttons.apply, {remove1: true, remove2: true}}, {buttons.close}} + manageBlockList ft + ut\assertEquals ft.calls.unblock, {"feed://user"} + + -- a typed URL is added as a block carrying the chosen mode and reason + addsBlockWithReason: (ut) -> + ft = makeFeedTrust {} + queueDialog ut, { + {buttons.apply, {newUrl: "ghuc://bad/feed", newMode: DepCtrl.FeedTrust.BlockMatchMode.Exact, newReason: "malware"}} + {buttons.close} + } + manageBlockList ft + ut\assertEquals ft.calls.block[1][1], "https://raw.githubusercontent.com/bad/feed" + ut\assertEquals ft.calls.block[1][2].matchMode, DepCtrl.FeedTrust.BlockMatchMode.Exact + ut\assertEquals ft.calls.block[1][2].reason, "malware" + + -- blocking DependencyControl's own bootstrap feed is refused (it would collapse the trust root) + refusesBootstrapBlock: (ut) -> + ft = makeFeedTrust {} + queueDialog ut, { + {buttons.apply, {newUrl: constants.DEPCTRL_FEED_URL, newMode: DepCtrl.FeedTrust.BlockMatchMode.Exact}} + {buttons.close} + } + manageBlockList ft + ut\assertEquals #ft.calls.block, 0 + + _order: {"close_makesNoChanges", "removesUserBlockNotOfficial", "addsBlockWithReason", "refusesBootstrapBlock"} + } + + ExtraFeeds: { + _description: "manageExtraFeeds: the discovery-roots panel — add a typed feed (expanding ghuc://), close cleanly." + + close_makesNoChanges: (ut) -> + ft = makeFeedTrust! + queueDialog ut, {{buttons.close}} + manageExtraFeeds ft + ut\assertEquals #ft.calls.addExtraFeed, 0 + ut\assertEquals #ft.calls.removeExtraFeed, 0 + + -- a typed ghuc:// shorthand is trimmed and expanded before being added as a discovery root + addsTypedFeedExpanded: (ut) -> + ft = makeFeedTrust! + queueDialog ut, {{buttons.apply, {newFeed: " ghuc://x/y "}}, {buttons.close}} + manageExtraFeeds ft + ut\assertEquals ft.calls.addExtraFeed, {"https://raw.githubusercontent.com/x/y"} + ut\assertEquals #ft.calls.removeExtraFeed, 0 + + _order: {"close_makesNoChanges", "addsTypedFeedExpanded"} + } + + Update: { + _description: "Update Script: picks installed packages and runs update tasks for the selection." + + -- cancelling the picker runs no update task + cancel_runsNoTask: (ut) -> + ran = {} + ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {macros: {"a.x": {name: "X", version: "1.0.0"}}, modules: {}}} + ut\stub(DepCtrl.updater, "addTask")\calls (self, sd) -> ran[#ran + 1] = sd + queueDialog ut, {{false}} + macros["Update Script"].process! + ut\assertEquals #ran, 0 + + -- selecting a macro runs an update task for exactly that package + selectsMacro_runsItsTask: (ut) -> + macroEntry = {name: "X", version: "1.0.0"} + ran = {} + ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {macros: {"a.x": macroEntry}, modules: {}}} + ut\stub(DepCtrl.updater, "addTask")\calls (self, sd) -> + ran[#ran + 1] = sd + {run: => true} + queueDialog ut, {{"OK", {macro: "X v1.0.0", module: ""}}} + macros["Update Script"].process! + ut\assertEquals #ran, 1 + ut\assertEquals ran[1], macroEntry + } + + Uninstall: { + _description: "Uninstall Script: builds the (protected-guarded) installed list and cancels cleanly." + + -- cancelling the picker uninstalls nothing (and never constructs a record to uninstall) + cancel_uninstallsNothing: (ut) -> + ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {macros: {"a.x": {name: "X", version: "1.0.0"}}, modules: {}}} + dlg = queueDialog ut, {{false}} + macros["Uninstall Script"].process! + dlg\assertCalledOnce! + } + + UninstallFlow: { + _description: "Uninstall Script: a picker selection reaches Record uninstall, and every result shape is reported without erroring." + + -- A Stub can't stand in for a metamethod (LuaJIT requires __call to be a plain function), so the + -- construction seam is swapped by hand and restored in the teardown, which runs even when a test fails. + _setup: (ut) -> + meta = getmetatable DepCtrl + uninstallSeam.original = meta.__call + meta.__call = (cls, script) -> uninstallSeam.record + + _teardown: (ut) -> + (getmetatable DepCtrl).__call = uninstallSeam.original + + success_reportsAndRuns: (ut) -> + ut\assertEquals runUninstall(ut, true, {}), 1 + + errorWithStringDetail_reported: (ut) -> + ut\assertEquals runUninstall(ut, nil, "record construction failed"), 1 + + errorWithFileTableDetail_formatted: (ut) -> + ut\assertEquals runUninstall(ut, nil, {["auto/x.moon"]: {nil, "access denied"}}), 1 + + lockedFiles_formatted: (ut) -> + ut\assertEquals runUninstall(ut, false, {["auto/x.moon"]: {false, "file in use"}}), 1 + + _order: {"success_reportsAndRuns", "errorWithStringDetail_reported", + "errorWithFileTableDetail_formatted", "lockedFiles_formatted"} + } + + Install: { + _description: "Install Script: crawls feeds for installable packages and runs install tasks for the selection." + + -- an unreachable (unfetched) feed contributes nothing; cancelling installs nothing + skipsUnfetchedAndCancels: (ut) -> + ran = {} + ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {macros: {}, modules: {}}} + crawl = ut\stub(DepCtrl.FeedInventory.__base, "crawl")\calls -> {{url: "feed://x", fetched: false}} + ut\stub(DepCtrl.updater, "addTask")\calls (self, sd) -> ran[#ran + 1] = sd + queueDialog ut, {{false}} + macros["Install Script"].process! + crawl\assertCalled! + ut\assertEquals #ran, 0 + + -- a fetched feed's available script is offered, and selecting it runs an install task for it + selectsAvailableScript: (ut) -> + ran = {} + ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {macros: {}, modules: {}}} + ut\stub(DepCtrl.FeedInventory.__base, "crawl")\calls -> {{url: "feed://x", fetched: true}} + ut\stub(DepCtrl.FeedLoader.__base, "load")\calls -> makeFakeFeed! + ut\stub(DepCtrl.updater, "addTask")\calls (self, sd) -> + ran[#ran + 1] = sd + {run: => true} + queueDialog ut, {{"OK", {macro: "X v1.0.0", module: ""}}} + macros["Install Script"].process! + ut\assertEquals #ran, 1 + ut\assertEquals ran[1].namespace, "a.x" + ut\assertEquals ran[1].feed, "feed://x" + + _order: {"skipsUnfetchedAndCancels", "selectsAvailableScript"} + } + + MacroConfig: { + _description: "Macro Configuration: writes non-empty edits into the per-macro config and saves." + + appliesEditsAndSaves: (ut) -> + saved = {} + cfg = { + userConfig: {"a.x": {name: "X"}} + c: {"a.x": {}} + save: => saved.yes = true + } + ut\stub(DepCtrl.config, "getSectionHandler")\returns cfg + dlg = queueDialog ut, {{"OK", {"a.x.customMenu": "Tools/Mine", "a.x.userFeed": ""}}} + macros["Macro Configuration"].process! + ut\assertEquals cfg.c["a.x"].customMenu, "Tools/Mine" -- non-empty edit written + ut\assertNil cfg.c["a.x"].userFeed -- empty edit left unset + ut\assertTrue saved.yes + -- the built dialog is a gap-free array whose first row sits at y=0 (no wasted leading row) + built = dlg._calls[1][1] + ut\assertNotNil built[1] + ut\assertEquals built[1].y, 0 + } + + ManageFeeds: { + _description: "Manage Feeds: the redisplay loop dispatches Apply to feed actions and Discover to the crawl." + + -- The offline gather consults the live trust singleton, whose official-set load fetches the DepCtrl + -- feed over the network on a cold cache; seed both accessors so no test run leaves the process. + _setup: (ut) -> + ut\stub(DepCtrl.updater.feedTrust, "getOfficialTrustedFeeds")\returns {} + ut\stub(DepCtrl.updater.feedTrust, "getOfficialBlockedFeeds")\returns {} + + -- Close on the first render applies no feed action + close_appliesNothing: (ut) -> + rows = {{url: "feed://x", provenance: {}, trustStatus: DepCtrl.FeedTrust.TrustStatus.Untrusted, actions: {}, browserUrl: "http://b"}} + ut\stub(DepCtrl.FeedManager, "buildRows")\calls -> rows + applyAction = ut\stub DepCtrl.FeedManager.__base, "applyAction" + queueDialog ut, {{buttons.close}} + macros["Manage Feeds"].process! + applyAction\assertNotCalled! + + -- Apply dispatches each row's chosen action to the feed manager + apply_dispatchesSelectedAction: (ut) -> + Trust = DepCtrl.FeedManager.FeedAction.Trust + row = {url: "feed://x", provenance: {}, trustStatus: DepCtrl.FeedTrust.TrustStatus.Untrusted, actions: {Trust}, browserUrl: "http://b"} + applied = {} + ut\stub(DepCtrl.FeedManager, "buildRows")\calls -> {row} + ut\stub(DepCtrl.FeedManager.__base, "applyAction")\calls (self, action, entry) -> + applied[#applied + 1] = {action, entry} + true + queueDialog ut, {{buttons.apply, {action1: feedActionLabels[Trust]}}, {buttons.close}} + macros["Manage Feeds"].process! + ut\assertEquals #applied, 1 + ut\assertEquals applied[1][1], Trust + ut\assertEquals applied[1][2], row + + -- Fetch/Discover switches the list source from the offline gather to the (here stubbed) network crawl + discover_usesCrawl: (ut) -> + rows = {{url: "feed://x", provenance: {}, trustStatus: DepCtrl.FeedTrust.TrustStatus.Untrusted, actions: {}, browserUrl: "http://b"}} + ut\stub(DepCtrl.FeedManager, "buildRows")\calls -> rows + crawl = ut\stub(DepCtrl.FeedInventory.__base, "crawl")\calls -> {} + queueDialog ut, {{buttons.discover}, {buttons.close}} + macros["Manage Feeds"].process! + crawl\assertCalled! + + -- Block asks for a reason before dispatching, and the reason travels in the action options + apply_blockPromptsForReason: (ut) -> + Block = DepCtrl.FeedManager.FeedAction.Block + row = {url: "feed://x", provenance: {}, trustStatus: DepCtrl.FeedTrust.TrustStatus.Untrusted, actions: {Block}, browserUrl: "http://b"} + applied = {} + ut\stub(DepCtrl.FeedManager, "buildRows")\calls -> {row} + ut\stub(DepCtrl.FeedManager.__base, "applyAction")\calls (self, action, entry, opts) -> + applied[#applied + 1] = {action, entry, opts} + true + ut\stub(DepCtrl.FeedInventory.__base, "getPackagesSourcedFrom")\returns {} + queueDialog ut, { + {buttons.apply, {action1: feedActionLabels[Block]}} + {buttons.block, {reason: "compromised"}} + {buttons.close} + } + macros["Manage Feeds"].process! + ut\assertEquals #applied, 1 + ut\assertEquals applied[1][1], Block + ut\assertEquals applied[1][3].reason, "compromised" + + -- declining the sourced-packages warning abandons the block before any prompt or dispatch + apply_blockDeclinedOnSourcedWarning: (ut) -> + Block = DepCtrl.FeedManager.FeedAction.Block + row = {url: "feed://x", provenance: {}, trustStatus: DepCtrl.FeedTrust.TrustStatus.Untrusted, actions: {Block}, browserUrl: "http://b"} + ut\stub(DepCtrl.FeedManager, "buildRows")\calls -> {row} + applyAction = ut\stub DepCtrl.FeedManager.__base, "applyAction" + ut\stub(DepCtrl.FeedInventory.__base, "getPackagesSourcedFrom")\returns {"a.pkg"} + queueDialog ut, { + {buttons.apply, {action1: feedActionLabels[Block]}} + {false} -- the sourced warning, declined + {buttons.close} + } + macros["Manage Feeds"].process! + applyAction\assertNotCalled! + + _order: {"close_appliesNothing", "apply_dispatchesSelectedAction", "discover_usesCrawl", + "apply_blockPromptsForReason", "apply_blockDeclinedOnSourcedWarning"} + } + + StartupSweep: { + _description: "scheduleUpdatesAndRegisterTests: the startup sweep schedules every record, registers module test menus, and survives per-record failures." + + -- every registered record gets a schedule attempt, module suites get their test menus registered + -- (automation scripts register their own), an installed module that fails to load is tolerated, + -- and the updater lock is released at the end + sweepsRecordsAndRegistersModuleTests: (ut) -> + ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {modules: {"toolbox.test.notARealModule": {}}}} + registered = {} + records = { + {name: "Mod", namespace: "a.mod", scriptType: Common.ScriptType.Module, tests: {registerMacros: => registered.mdl = true}} + {name: "Mac", namespace: "a.mac", scriptType: Common.ScriptType.Automation, tests: {registerMacros: => registered.macro = true}} + } + ut\stub(DepCtrl, "getAllRegisteredRecords")\returns records + scheduled = {} + ut\stub(DepCtrl.updater, "scheduleUpdate")\calls (self, record) -> + scheduled[#scheduled + 1] = record.namespace + 0 + released = ut\stub DepCtrl.updater, "releaseLock" + scheduleUpdatesAndRegisterTests! + ut\assertEquals #scheduled, 2 + ut\assertTrue registered.mdl -- module suites are registered by the sweep + ut\assertNil registered.macro -- automation scripts register their own + released\assertCalledOnce! + + -- one record's raising scheduleUpdate (or a negative status) doesn't end the sweep for the rest + toleratesPerRecordFailures: (ut) -> + ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {modules: {}}} + records = { + {name: "Boom", namespace: "a.boom", scriptType: Common.ScriptType.Module} + {name: "Denied", namespace: "a.denied", scriptType: Common.ScriptType.Module} + {name: "Fine", namespace: "a.fine", scriptType: Common.ScriptType.Automation} + } + ut\stub(DepCtrl, "getAllRegisteredRecords")\returns records + calls = 0 + ut\stub(DepCtrl.updater, "scheduleUpdate")\calls (self, record) -> + calls += 1 + error "scheduler exploded" if record.name == "Boom" + record.name == "Denied" and -1 or 0 + ut\stub DepCtrl.updater, "releaseLock" + scheduleUpdatesAndRegisterTests! + ut\assertEquals calls, 3 + + _order: {"sweepsRecordsAndRegistersModuleTests", "toleratesPerRecordFailures"} + } + } 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/Logger.moon b/modules/DependencyControl/Logger.moon deleted file mode 100644 index 17a5abf..0000000 --- a/modules/DependencyControl/Logger.moon +++ /dev/null @@ -1,185 +0,0 @@ -PreciseTimer = require "PT.PreciseTimer" -lfs = require "lfs" - -class Logger - levels = {"fatal", "error", "warning", "hint", "debug", "trace"} - defaultLevel: 2 - maxToFileLevel: 5 - fileBaseName: script_namespace or "UNKNOWN" - fileSubName: "" - logDir: "?user/log" - fileTemplate: "%s/%s-%04x_%s_%s.log" - fileMatchTemplate: "%d%d%d%d%-%d%d%-%d%d%-%d%d%-%d%d%-%d%d%-%x%x%x%x_@{fileBaseName}_?.*%.log$" - prefix: "" - toFile: false, toWindow: true - indent: 0 - usePrefixFile: true - usePrefixWindow: true - indentStr: "—" - maxFiles: 200, maxAge: 604800, maxSize:10*(10^6) - - timer, seeded = PreciseTimer!, false - - new: (args) => - if args - @[k] = v for k, v in pairs args - if args.usePrefix ~= nil - @usePrefixFile, @usePrefixWindow = args.usePrefix - - -- scripts are loaded simultaneously, so we need to avoid seeding the rng with the same time - unless seeded - timer.sleep 10 for i=1,50 - math.randomseed(timer\timeElapsed!*1000000) - math.random, math.random, math.random - seeded = true - -- timer gets freed on garbage collection - timer = nil - - @lastHadLineFeed = true - escaped = @fileBaseName\gsub("([%%%(%)%[%]%.%*%-%+%?%$%^])","%%%1") - @fileMatch = @fileMatchTemplate\gsub "@{fileBaseName}", escaped - @fileName = @fileTemplate\format aegisub.decode_path(@logDir), os.date("%Y-%m-%d-%H-%M-%S"), - math.random(0, 16^4-1), @fileBaseName, @fileSubName - - logEx: (level = @defaultLevel, msg = "", insertLineFeed = true, prefix = @prefix, indent = @indent, ...) => - return false if msg == "" - - prefixWin = @usePrefixWindow and prefix or "" - lineFeed = insertLineFeed and "\n" or "" - indentStr = indent==0 and "" or @indentStr\rep(indent) .. " " - - msg = if @lastHadLineFeed - @format msg, indent, ... - elseif 0 < select "#", ... - msg\format ... - - show = aegisub.log and @toWindow - if @toFile and level <= @maxToFileLevel - @handle = io.open(@fileName, "a") unless @handle - linePre = @lastHadLineFeed and "#{indentStr}[#{levels[level+1]\upper!}] #{os.date '%H:%M:%S'} #{show and '+' or '•'} " or "" - line = table.concat({linePre, @usePrefixFile and prefix or "", msg, lineFeed}) - @handle\write(line)\flush! - - -- for some reason the stack trace gets swallowed when not doing the replace - assert level > 1,"#{indentStr}Error: #{prefixWin}#{msg\gsub ':', ': '}" - if show - aegisub.log level, table.concat({indentStr, prefixWin, msg, lineFeed}) - - @lastHadLineFeed = insertLineFeed - return true - - format: (msg, indent, ...) => - if type(msg) == "table" - msg = table.concat msg, "\n" - - if 0 < select "#", ... - msg = msg\format ... - - return msg unless indent>0 - - indentRep = @indentStr\rep(indent) - indentStr = indentRep .. " " - -- indent after line breaks and connect indentation supplied in the user message - return msg\gsub("\n", "\n"..indentStr)\gsub "\n#{indentStr}(#{@indentStr})", "\n#{indentRep}%1" - - log: (level, msg, ...) => - return false unless level or msg - - if "number" != type level - return @logEx @defaultLevel, level, true, nil, nil, msg, ... - else return @logEx level, msg, true, nil, nil, ... - - fatal: (...) => @log 0, ... - error: (...) => @log 1, ... - warn: (...) => @log 2, ... - hint: (...) => @log 3, ... - debug: (...) => @log 4, ... - trace: (...) => @log 5, ... - - assert: (cond, ...) => - if not cond - @log 1, ... - else return cond - - progress: (progress=false, msg = "", ...) => - if @progressStep and not progress - @logEx nil, "■"\rep(10-@progressStep).."]", true, "" - @progressStep = nil - elseif progress - unless @progressStep - @progressStep = 0 - msg ..= " " if #msg>0 - @logEx nil, "#{msg}[", false, nil, nil, ... - step = math.floor(progress * 0.01 + 0.5) / 0.1 - @logEx nil, "■"\rep(step-@progressStep), false, "" - @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 ) => - if "table" != type item - return tostring item - - count, tablecount = 1, 1 - - result = { "{ @#{tablecount}" } - seen = { [item]: tablecount } - recurse = ( item, space ) -> - for key, value in pairs item - unless key == ignore - if "number" == type key - key = "##{key}" - if "table" == type value - unless seen[value] - tablecount += 1 - seen[value] = tablecount - count += 1 - result[count] = space .. "#{key}: { @#{tablecount}" - recurse value, space .. " " - count += 1 - result[count] = space .. "}" - else - count += 1 - result[count] = space .. "#{key}: @#{seen[value]}" - - else - if "string" == type value - value = ("%q")\format value - - count += 1 - result[count] = space .. "#{key}: #{value}" - - recurse item, " " - result[count+1] = "}" - - return table.concat(result, "\n")\gsub "%%", "%%%%" - - windowError: ( errorMessage ) -> - aegisub.dialog.display { { class: "label", label: errorMessage } }, { "&Close" }, { cancel: "&Close" } - aegisub.cancel! - - - trimFiles: (doWipe, maxAge = @maxAge, maxSize = @maxSize, maxFiles = @maxFiles) => - files, totalSize, deletedSize, now, f = {}, 0, 0, os.time!, 0 - - dir = aegisub.decode_path @logDir - lfs.chdir dir - for file in lfs.dir dir - attr = lfs.attributes file - if type(attr) == "table" and attr.mode == "file" and file\find @fileMatch - f += 1 - files[f] = {name:file, modified:attr.modification, size:attr.size} - - table.sort files, (a,b) -> a.modified > b.modified - total, kept = #files, 0 - - for i, file in ipairs files - totalSize += file.size - if doWipe or kept > maxFiles or totalSize > maxSize or file.modified+maxAge < now - deletedSize += file.size - os.remove file.name - else - kept += 1 - return total-kept, deletedSize, total, totalSize 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 <last part of namespace>.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 "<no date>"} - 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/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon deleted file mode 100644 index 888a105..0000000 --- a/modules/DependencyControl/Updater.moon +++ /dev/null @@ -1,524 +0,0 @@ -lfs = require "lfs" -DownloadManager = require "DM.DownloadManager" -PreciseTimer = require "PT.PreciseTimer" - -UpdateFeed = require "l0.DependencyControl.UpdateFeed" -fileOps = require "l0.DependencyControl.FileOps" -Logger = require "l0.DependencyControl.Logger" -Common = require "l0.DependencyControl.Common" -ModuleLoader = require "l0.DependencyControl.ModuleLoader" -DependencyControl = nil - -class UpdaterBase extends Common - @logger = Logger fileBaseName: "DependencyControl.Updater" - msgs = { - updateError: { - [0]: "Couldn't %s %s '%s' because of a paradox: module not found but updater says up-to-date (%s)" - [1]: "Couldn't %s %s '%s' because the updater is disabled." - [2]: "Skipping %s of %s '%s': namespace '%s' doesn't conform to rules." - [3]: "Skipping %s of unmanaged %s '%s'." - [4]: "No remaining feed available to %s %s '%s' from." - [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." - [10]: "Skipped %s of %s '%s': the update task is already running." - [15]: "Couldn't %s %s '%s' because its requirements could not be satisfied:" - [30]: "Couldn't %s %s '%s': failed to create temporary download directory %s" - [35]: "Aborted %s of %s '%s' because the feed contained a missing or malformed SHA-1 hash for file %s." - [50]: "Couldn't finish %s of %s '%s' because some files couldn't be moved to their target location:\n" - [55]: "%s of %s '%s' succeeded, couldn't be located by the module loader." - [56]: "%s of %s '%s' succeeded, but an error occured while loading the module:\n%s" - [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" - [100]: "Error (%d) in component %s during %s of %s '%s':\n— %s" - } - updaterErrorComponent: {"DownloadManager (adding download)", "DownloadManager"} - } - - getUpdaterErrorMsg: (code, name, scriptType, isInstall, detailMsg) => - if code <= -100 - -- Generic downstream error - return msgs.updateError[100]\format -code, msgs.updaterErrorComponent[math.floor(-code/100)], - @@terms.isInstall[isInstall], @@terms.scriptType.singular[scriptType], name, detailMsg - else - -- Updater error: - return msgs.updateError[-code]\format @@terms.isInstall[isInstall], - @@terms.scriptType.singular[scriptType], - name, detailMsg - -class UpdateTask extends UpdaterBase - dlm = DownloadManager! - msgs = { - checkFeed: { - downloadFailed: "Failed to download feed: %s" - noData: "The feed doesn't have any update information for %s '%s'." - badChannel: "The specified update channel '%s' wasn't present in the feed." - invalidVersion: "The feed contains an invalid version record for %s '%s' (channel: %s): %s." - unsupportedPlatform: "No download available for your platform '%s' (channel: %s)." - noFiles: "No files available to download for your platform '%s' (channel: %s)." - } - run: { - starting: "Starting %s of %s '%s'... " - fetching: "Trying to %sfetch missing %s '%s'..." - feedCandidates: "Trying %d candidate feeds (%s mode)..." - feedTrying: "Checking feed %d/%d (%s)..." - upToDate: "The %s '%s' is up-to-date (v%s)." - alreadyUpdated: "%s v%s has already been installed." - noFeedAvailExt: "(required: %s; installed: %s; available: %s)" - noUpdate: "Feed has no new update." - skippedOptional: "Skipped %s of optional dependency '%s': %s" - optionalNoFeed: "No feed available to download module from." - optionalNoUpdate: "No suitable download could be found %s." - } - - performUpdate: { - updateReqs: "Checking requirements..." - updateReady: "Update ready. Using temporary directory '%s'." - fileUnchanged: "Skipped unchanged file '%s'." - fileAddDownload: "Added Download %s ==> '%s'." - filesDownloading: "Downloading %d files..." - movingFiles: "Downloads complete. Now moving files to Aegisub automation directory '%s'..." - movedFile: "Moved '%s' ==> '%s'." - moveFileFailed: "Failed to move '%s' ==> '%s': %s" - updSuccess: "%s of %s '%s' (v%s) complete." - reloadNotice: "Please rescan your autoload directory for the changes to take effect." - 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." - } - } - - new: (@record, targetVersion = 0, @addFeeds, @exhaustive, @channel, @optional, @updater) => - DependencyControl or= require "l0.DependencyControl" - assert @record.__class == DependencyControl, "First parameter must be a #{DependencyControl.__name} object." - - @logger = @updater.logger - @triedFeeds = {} - @status = nil - @targetVersion = DependencyControl\parseVersion targetVersion - - -- set UpdateFeed settings - @feedConfig = { - downloadPath: aegisub.decode_path "?user/feedDump/" - dumpExpanded: true - } if @updater.config.c.dumpFeeds - - 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 @ - - checkFeed: (feedUrl) => - -- get feed contents - feed = UpdateFeed feedUrl, false, nil, @feedConfig, @logger - unless feed.data -- no cached data available, perform download - success, err = feed\fetch! - unless success - return nil, msgs.checkFeed.downloadFailed\format err - - -- select our script and update channel - updateRecord = feed\getScript @record.namespace, @record.scriptType, @record.config, false - unless updateRecord - return nil, msgs.checkFeed.noData\format @@terms.scriptType.singular[@record.scriptType], @record.name - - success, currentChannel = updateRecord\setChannel @channel - unless success - return nil, msgs.checkFeed.badChannel\format currentChannel - - -- check if an update is available and satisfies our requirements - res, version = @record\checkVersion updateRecord.version - if res == nil - return nil, msgs.checkFeed.invalidVersion\format @@terms.scriptType.singular[@record.scriptType], - @record.name, currentChannel, tostring updateRecord.version - elseif res or @targetVersion > version - return false, nil, version - - -- check if our platform is supported/files are available to download - res, platform = updateRecord\checkPlatform! - unless res - return nil, msgs.checkFeed.unsupportedPlatform\format platform, currentChannel - if #updateRecord.files == 0 - return nil, msgs.checkFeed.noFiles\format platform, currentChannel - - return true, updateRecord, version - - - run: (waitLock, exhaustive = @updater.config.c.tryAllFeeds or @@exhaustive) => - logUpdateError = (code, extErr, virtual = @virtual) -> - if code < 0 - @logger\log @getUpdaterErrorMsg code, @record.name, @record.scriptType, virtual, extErr - return code, extErr - - with @record do @logger\log msgs.run.starting, @@terms.isInstall[.virtual], - @@terms.scriptType.singular[.scriptType], .name - - -- don't perform update of a script when another one is already running for the same script - return logUpdateError -10 if @running - - -- 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 - return 2 - - -- build feed list - userFeed, haveFeeds, feeds = @record.config.c.userFeed, {}, {} - if userFeed and not @triedFeeds[userFeed] - feeds[1] = userFeed - else - unless @triedFeeds[@record.feed] or haveFeeds[@record.feed] - feeds[1] = @record.feed - for feed in *@addFeeds - unless @triedFeeds[feed] or haveFeeds[feed] - feeds[#feeds+1] = feed - haveFeeds[feed] = true - - for feed in *@updater.config.c.extraFeeds - unless @triedFeeds[feed] or haveFeeds[feed] - feeds[#feeds+1] = feed - haveFeeds[feed] = true - - if #feeds == 0 - if @optional - @logger\log msgs.run.skippedOptional, @record.name, - @@terms.isInstall[@record.virtual], msgs.run.optionalNoFeed - return 3 - - return logUpdateError -4 - - -- check internet connection - return logUpdateError -7 unless dlm\isInternetConnected! - - -- get a lock on the updater - success, otherHost = @updater\getLock waitLock - return logUpdateError -5, otherHost unless success - - -- check feeds for update until we find and update or run out of feeds to check - -- normal mode: check feeds until an update matching the required version is found - -- exhaustive mode: check all feeds for updates and pick the highest version - - @logger\log msgs.run.feedCandidates, #feeds, exhaustive and "exhaustive" or "normal" - @logger.indent += 1 - - maxVer, updateRecord = 0 - for i, feed in ipairs feeds - @logger\log msgs.run.feedTrying, i, #feeds, feed - - res, rec, version = @checkFeed feed - @triedFeeds[feed] = true - if res == nil - @logger\log rec - elseif version > maxVer - maxVer = version - if res - updateRecord = rec - break unless exhaustive - else @logger\trace msgs.run.noUpdate - else - @logger\trace msgs.run.noUpdate - - @logger.indent -= 1 - - local code, res - wasVirtual = @record.virtual - unless updateRecord - -- for a script to be marked up-to-date it has to installed on the user's system - -- 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 - 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 - - if @optional - @logger\log msgs.run.skippedOptional, @record.name, @@terms.isInstall[@record.virtual], - msgs.run.optionalNoUpdate\format res - return 3 - - return logUpdateError -6, res - - code, res = @performUpdate updateRecord - return logUpdateError code, res, wasVirtual - - performUpdate: (update) => - finish = (...) -> - @running = false - if @record.virtual or @record.recordType == @@RecordType.Unmanaged - ModuleLoader.removeDummyRef @record - return ... - - -- don't perform update of a script when another one is already running for the same script - return finish -10 if @running - @running = true - - -- set a dummy ref (which hasn't yet been set for virtual and unmanaged modules) - -- and record version to allow resolving circular dependencies - if @record.virtual or @record.recordType == @@RecordType.Unmanaged - ModuleLoader.createDummyRef @record - @record\setVersion update.version - - -- try to load required modules first to see if all dependencies are satisfied - -- this may trigger more updates - reqs = update.requiredModules - if reqs and #reqs > 0 - @logger\log msgs.performUpdate.updateReqs - @logger.indent += 1 - success, err = ModuleLoader.loadModules @record, reqs, {@record.feed} - @logger.indent -= 1 - unless success - @logger.indent += 1 - @logger\log err - @logger.indent -= 1 - return finish -15, err - - -- since circular dependencies are possible, our task may have completed in the meantime - -- so check again if we still need to update - return finish 2 if @updated and @record\checkVersion update.version - - - -- 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}" - res, dir = fileOps.mkdir tmpDir - return finish -30, "#{tmpDir} (#{dir})" if res == nil - - @logger\log msgs.performUpdate.updateReady, tmpDir - - scriptSubDir = @record.namespace - scriptSubDir = scriptSubDir\gsub "%.","/" if @record.scriptType == @@ScriptType.Module - - dlm\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)" - else - file.unknown = true - @logger\log msgs.performUpdate.unknownType, file.name, file.type - continue - continue if file.delete - - 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 - @logger\trace msgs.performUpdate.fileUnchanged, prettyName - continue - - dl, err = dlm\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 - @logger\progress! - - if #dlm.failedDownloads>0 - err = @logger\format ["#{dl.url}: #{dl.error}" for dl in *dlm.failedDownloads], 1 - return finish -245, err - - - -- move files to their destination directory and clean up - - @logger\log msgs.performUpdate.movingFiles, @record.automationDir - moveErrors = {} - @logger.indent += 1 - for dl in *dlm.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 - if res - @logger\trace msgs.performUpdate.movedFile, dl.outfile, dl.targetFile - else - @logger\log msgs.performUpdate.moveFileFailed, dl.outfile, dl.targetFile, err - moveErrors[#moveErrors+1] = err - @logger.indent -= 1 - - if #moveErrors>0 - return finish -50, @logger\format moveErrors, 1 - else lfs.rmdir tmpDir - os.remove file.fullName for file in *update.files when file.delete and not file.unknown - - -- Nuke old module refs and reload - oldVer, wasVirtual = @record.version, @record.virtual - - -- Update complete, refresh module information/configuration - if @record.scriptType == @@ScriptType.Module - ref = ModuleLoader.loadModule @record, @record, false, true - unless ref - if @record._error - return finish -56, @logger\format @record._error, 1 - else return finish -55 - - -- get a fresh version record - if type(ref.version) == "table" and ref.version.__class.__name == DependencyControl.__name - @record = ref.version - else - -- look for any compatible non-DepCtrl version records and create an unmanaged record - return finish -57 unless ref.version - success, rec = pcall DependencyControl, { moduleName: @record.moduleName, version: ref.version, - recordType: @@RecordType.Unmanaged, name: @record.name } - return finish -58, rec unless success - @record = rec - @ref = ref - - else with @record - .name, .version, .virtual = @record.name, DependencyControl\parseVersion update.version - @record\writeConfig! - - @updated = true - @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual]), - @@terms.scriptType.singular[@record.scriptType], - @record.name, DependencyControl\getVersionString @record.version - - -- Diplay changelog - @logger\log update\getChangelog @record, (DependencyControl\parseVersion 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 - - - refreshRecord: => - with @record - wasVirtual, oldVersion = .virtual, .version - \loadConfig true - if wasVirtual and not .virtual or .version > oldVersion - @updated = true - @ref = ModuleLoader.loadModule @record, @record, false, true if .scriptType == @@ScriptType.Module - if wasVirtual - @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 - -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..." - } - require: { - macroPassed: "%s is not a module." - upToDate: "Tried to require an update for up-to-date module '%s'." - } - scheduleUpdate: { - updaterDisabled: "Skipping update check for %s (Updater disabled)." - runningUpdate: "Running scheduled update for %s '%s'..." - } - } - new: (@host = script_namespace, @config, @logger = @@logger) => - @tasks = {scriptType, {} for _, scriptType in pairs @@ScriptType when "number" == type scriptType} - - addTask: (record, targetVersion, addFeeds = {}, exhaustive, channel, optional) => - DependencyControl or= require "l0.DependencyControl" - if record.__class != DependencyControl - depRec = {saveRecordToConfig: false, readGlobalScriptVars: false} - 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 - - 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, ... - code, res = task\run true if task - - if code == 0 and not task.updated - -- usually we know in advance if a module is up to date so there's no reason to block other updaters - -- but we'll make sure to handle this case gracefully, anyway - @logger\debug msgs.require.upToDate, task.record.name or task.record.namespace - return ModuleLoader.loadModule task.record, task.record.namespace - elseif code >= 0 - return task.ref - else -- pass on update errors - return nil, code, res - - scheduleUpdate: (record) => - unless @config.c.updaterEnabled - @logger\trace msgs.scheduleUpdate.updaterDisabled, record.name or record.namespace - return -1 - - -- no regular updates for non-existing or unmanaged modules - if record.virtual or record.recordType == @@RecordType.Unmanaged - return -3 - - -- 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 - - record.config.c.lastUpdateCheck = os.time! - record.config\write! - - 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) => - return true if @hasLock - - @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 - - -- 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] - - return true - - releaseLock: => - return false unless @hasLock - @hasLock = false - @config.c.updaterRunning = false - @config\write! \ 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..4ef4f7a --- /dev/null +++ b/modules/l0/AegisubShims/aegisub.moon @@ -0,0 +1,186 @@ +-- 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 + +---Writes a log message to stderr, interpolating printf-style arguments like real Aegisub. Accepts both +---call forms: `(msg, ...)` and `(level, msg, ...)` with a numeric level, which is ignored here. +---The message is written verbatim with no trailing newline: callers append their own, so adding one +---would break same-line output. +---@param level number|string The numeric log level, or the message when the level is omitted. +---@param msg? any The message (or its first format argument in the level-less form). +---@param ... any Arguments interpolated into the message with string.format. +writeLog = (level, msg, ...) -> + local text + if type(level) == "string" + text = if msg != nil then level\format msg, ... else level + else + text = if select("#", ...) > 0 then msg\format ... else msg + io.stderr\write tostring text or "" + +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: writeLog + + debug: { + out: writeLog + } + + progress: { + set: -> nil + task: -> nil + title: -> nil + is_cancelled: -> false + } + + dialog: { + -- every headless dialog reports a cancel: button false, plus an empty result-values table + 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..c7b327a --- /dev/null +++ b/modules/l0/DependencyControl.moon @@ -0,0 +1,105 @@ +MIN_MOONSCRIPT_VERSION = "0.3.0" + +SemanticVersion = require "l0.DependencyControl.SemanticVersion" +moonscript = require 'moonscript.version' +assert SemanticVersion\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" +FeedInventory = require "l0.DependencyControl.FeedInventory" +FeedLoader = require "l0.DependencyControl.FeedLoader" +FeedManager = require "l0.DependencyControl.FeedManager" +FeedTrust = require "l0.DependencyControl.FeedTrust" +FileOps = require "l0.DependencyControl.FileOps" +Finalizer = require "l0.DependencyControl.Finalizer" +GitRepository = require "l0.DependencyControl.GitRepository" +Host = require "l0.DependencyControl.Host" +Lock = require "l0.DependencyControl.Lock" +Logger = require "l0.DependencyControl.Logger" +Record = require "l0.DependencyControl.Record" +Accessors = require "l0.DependencyControl.Accessors" +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 + @FeedInventory = FeedInventory + @FeedLoader = FeedLoader + @FeedManager = FeedManager + @FeedTrust = FeedTrust + @FileOps = FileOps + @GitRepository = GitRepository + @Host = Host + @Lock = Lock + @Logger = Logger + @Record = Record + @Stub = Stub + @Timer = Timer + @UpdateFeed = UpdateFeed + @Updater = Updater + @UnitTestSuite = UnitTestSuite + @Finalizer = Finalizer + @SemanticVersion = SemanticVersion + +-- inherit Record's version accessor before constructing any instance below +Accessors.install DependencyControl + +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", + provides: { + {name: "BM.BadMutex", version: "^0.1.3"}, + {name: "DM.DownloadManager", version: "^0.3.1"}, + {name: "PT.PreciseTimer", version: "^0.1.6"}, + } +} +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/Accessors.moon b/modules/l0/DependencyControl/Accessors.moon new file mode 100644 index 0000000..7e9b687 --- /dev/null +++ b/modules/l0/DependencyControl/Accessors.moon @@ -0,0 +1,97 @@ +constants = require "l0.DependencyControl.Constants" + +-- marks an AccessorSpec so install can pick specs out of a class's __base entries +ACCESSOR_TAG = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}AccessorSpec" + +msgs = { + property: { + badSpec: "Accessors.property expects a spec table, got a %s." + emptySpec: "Accessors.property needs a `get` and/or `set` function; got neither." + badFunction: "Accessors.property `%s` must be a function, got a %s." + } + install: { + badClass: "Accessors.install expects a class with a `__base` table, got a %s." + readOnly: "Can't set read-only property '%s'." + } +} + +---An opaque computed-property spec produced by `property` and consumed by `install`. Not indexed directly. +---@class AccessorSpec + +---Per-property capability flags recorded on a class's `__accessors` registry after install. +---@alias AccessorInfo { get: boolean, set: boolean } + +---Declares metatable-backed get/set computed properties on a class through one standardized transform: +---`property` marks a property in a class body; `install`, called once after the body, wires the instance +---metatable to dispatch those keys to the getter/setter and records each on the class's `__accessors`. +---@class Accessors +class Accessors + ---Declares a computed property to place at a key in a class body; wire the class up with install. + ---Omitting `set` makes the property read-only, so a write raises at runtime. + ---@param spec { get?: (fun(self: any): any), set?: (fun(self: any, value: any)) } Getter and/or setter (at least one). + ---@return AccessorSpec spec Assign this to the property's key in the class body. + @property = (spec) -> + error msgs.property.badSpec\format type spec unless type(spec) == "table" + error msgs.property.emptySpec unless spec.get or spec.set + error msgs.property.badFunction\format("get", type spec.get) if spec.get and type(spec.get) != "function" + error msgs.property.badFunction\format("set", type spec.set) if spec.set and type(spec.set) != "function" + {[ACCESSOR_TAG]: true, get: spec.get, set: spec.set} + + ---Installs standardized get/set dispatch for every property declared with `property` in a class's + ---body, plus any inherited from a parent class that ran install, recording each on the class's + ---`__accessors` registry. Call once, immediately after the class body (and after the parent's install + ---for a subclass). Reading a getter-less property falls through to normal lookup; writing a setter-less + ---one raises. + ---@param cls table The class whose `__base` carries the property specs. + ---@return table cls The same class, for chaining. + @install = (cls) -> + base = type(cls) == "table" and cls.__base + error msgs.install.badClass\format type cls unless type(base) == "table" + + -- a subclass's __base has the parent __base as its metatable, so plain pairs here would run the inherited + -- __pairs against the base table, whose getters have no instance to read. next avoids that + own, specKeys = {}, {} + for key, value in next, base + if type(value) == "table" and value[ACCESSOR_TAG] + own[key] = {get: value.get, set: value.set} + specKeys[#specKeys + 1] = key + base[key] = nil for key in *specKeys -- drop the sentinels so they aren't served as raw fields + + -- a function __index doesn't reach a child instance with the right self through the parent chain, so a + -- subclass must dispatch its inherited accessors from its own __base + accessors = {} + if parent = cls.__parent + if inherited = parent.__accessorSpecs + accessors[name] = spec for name, spec in pairs inherited + accessors[name] = spec for name, spec in pairs own + + cls.__accessorSpecs = accessors -- for a subclass's install to inherit + cls.__accessors = {name, {get: spec.get != nil, set: spec.set != nil} for name, spec in pairs accessors} + return cls unless next accessors + + fallback = base.__index + base.__index = (self, key) -> + accessor = accessors[key] + return accessor.get self if accessor and accessor.get + return fallback self, key if type(fallback) == "function" + fallback[key] + base.__newindex = (self, key, value) -> + accessor = accessors[key] + if accessor + error msgs.install.readOnly\format(key), 2 unless accessor.set + return accessor.set self, value + rawset self, key, value + + -- makes computed properties appear in pairs(instance), which requires LUAJIT_ENABLE_LUA52COMPAT + readable = [name for name, spec in pairs accessors when spec.get] + base.__pairs = (self) -> + i, key, rawDone = 0, nil, false + -> + unless rawDone + key, value = next self, key + return key, value if key != nil + rawDone = true + i += 1 + name = readable[i] + return name, accessors[name].get self if name + cls diff --git a/modules/l0/DependencyControl/Common.moon b/modules/l0/DependencyControl/Common.moon new file mode 100644 index 0000000..6a6877b --- /dev/null +++ b/modules/l0/DependencyControl/Common.moon @@ -0,0 +1,372 @@ +ffi = require "ffi" +Crypto = require "l0.DependencyControl.Crypto" +Enum = require "l0.DependencyControl.Enum" +constants = require "l0.DependencyControl.Constants" + +---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 + + -- this key's value cycles back to a table already being compared up the stack; treat the key + -- as matched and move on to the remaining keys (returning true here would wrongly declare the + -- whole tables equal and would also skip the depth decrement) + cyclic = false + for i = 1, depth + if v == treeA[i] and b[k] == treeB[i] + cyclic = true + break + + unless cyclic or 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] -= 1 + return true + return false + + if onlyNumKeys + aCnt, bCnt = #a, #b + return false if not ignoreExtraAItems and aCnt != bCnt + + for v in *a + seen[v] = (seen[v] or 0) + 1 -- multiset: track occurrences so duplicate items match by count + if "table" == type v + aTblCnt += 1 + aTbls[aTblCnt] = v + + for v in *b + if (seen[v] or 0) > 0 + seen[v] -= 1 + continue + + if type(v) != "table" or requireIdenticalItems or not findEqualTable v + return false + + else + for _, v in pairs a + aCnt += 1 + seen[v] = (seen[v] or 0) + 1 -- multiset: track occurrences so duplicate items match by count + if "table" == type v + aTblCnt += 1 + aTbls[aTblCnt] = v + + for _, v in pairs b + bCnt += 1 + if (seen[v] or 0) > 0 + seen[v] -= 1 + 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" + + ---@alias RecordType + ---| "managed" # Managed: a script/module DependencyControl installs and keeps up to date + ---| "unmanaged" # Unmanaged: a record describing a module DependencyControl tracks but does not update + RecordType = Enum "RecordType", { + Managed: "managed" + Unmanaged: "unmanaged" + } + @RecordType = RecordType + + ---@alias ScriptType + ---| "automation" # Automation: an automation script (macro / applied filter) + ---| "module" # Module: a require()-able module + ScriptType = Enum "ScriptType", { + Automation: "automation" + Module: "module" + } + @ScriptType = ScriptType + + ---@alias ScriptTypeSection + ---| "macros" # Automation scripts are stored in the "macros" section + ---| "modules" # Modules are stored in the "modules" section + ---Per-script type property names used in update feed data and config files. + @ScriptTypeSection = Enum "ScriptTypeSection", { + [ScriptType.Automation]: "macros" + [ScriptType.Module]: "modules" + } + + ---@alias FetchUntrustedFeeds + ---| "always" # Always: fetch untrusted feeds without asking (the default) + ---| "never" # Never: never fetch untrusted feeds + ---| "prompt" # Prompt: ask before fetching an untrusted feed; falls back to Never where no prompter is available (e.g. headless) + ---User policy for whether DependencyControl fetches feeds that are neither trusted nor blocked. + ---`prompt`only applies to feed discovery; dependency resolution always fetches and instead + ---gates *installing* from an untrusted feed via the `feedTrustPromptThreshold` setting. + @FetchUntrustedFeeds = Enum "FetchUntrustedFeeds", { + Always: "always" + Never: "never" + Prompt: "prompt" + } + + @terms = { + scriptType: { + singular: { + [ScriptType.Automation]: "automation script" + [ScriptType.Module]: "module" + } + plural: { + [ScriptType.Automation]: "automation scripts" + [ScriptType.Module]: "modules" + } + } + + isInstall: { + [true]: "installation" + [false]: "update" + } + + capitalize: (str) -> (str\sub 1, 1)\upper! .. str\sub 2 + } + + ---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 + + ---Returns the Aegisub directory that scripts of the given type are installed to. + ---@param scriptType ScriptType Whether to resolve the automation-script or module directory. + ---@param rootDir? string Aegisub path token to resolve against (defaults to "?user"). + ---@return string? dir Absolute directory path, or nil for an unrecognized script type. + @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 + + ---Returns the DepUnit test directory for scripts of the given type. + ---@param scriptType ScriptType Whether to resolve the automation-script or module test directory. + ---@param rootDir? string Aegisub path token to resolve against (defaults to "?user"). + ---@return string? dir Absolute directory path, or nil for an unrecognized script type. + @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 + + ---Whether DependencyControl is running headless — outside a real Aegisub session, on the Aegisub + ---shims (the CLI and unit test runner). Lets a script skip Aegisub-session-only startup work. + ---@return boolean headless + @isHeadless = -> aegisub[constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX] != 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 + + ---Builds (or extends) a set from an array's values: each value becomes a key mapped to `value`. + ---@param source any[] Array whose values become the set's keys. + ---@param target? table Table to populate (default a new table). + ---@param overwrite? boolean Overwrite keys already present in `target` (default true). + ---@param value? any Value to map each key to (default true). + ---@return table set The populated `target`. + @makeSet = (source, target = {}, overwrite = true, value = true) -> + target[v] = value for v in *source when overwrite or not target[v] + return target + + ---Reports whether an array contains a value (compared with `==`). + ---@param list any[] The array to search. + ---@param value any The value to look for. + ---@return boolean included Whether `value` is an element of `list`. + @listIncludes = (list, value) -> + for entry in *list + return true if entry == value + return false + + ---Fills in missing entries of `tbl` from `defaults`, mutating `tbl` in place. + ---@param tbl table The table to fill in. + ---@param defaults table Default key/value pairs. + ---@param predicate? fun(value: any, key: any, tbl: table): boolean Per-key test for whether to apply the default; when omitted, defaults apply wherever `tbl[key]` is nil. + ---@return number addedCount The number of defaults applied. + @addDefaults = (tbl, defaults, predicate) -> + addedCnt = 0 + for k, v in pairs defaults + if not predicate and tbl[k] == nil or predicate and predicate tbl[k], k, tbl + addedCnt += 1 + tbl[k] = v + return addedCnt + + ---Strips leading and trailing whitespace from a string. + ---@param str string The string to trim. + ---@return string trimmed The trimmed string. + @trim = (str) -> (str\gsub "^%s*(.-)%s*$", "%1") + + ---Escapes all Lua pattern magic characters in a string so it matches literally. + ---@param str string The string to escape. + ---@return string escaped The escaped string. + @escapePattern = (str) -> (str\gsub "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%1") + + ---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 + + ---Generates a random RFC-4122 version-4 UUID string. + ---@return string uuid + @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 diff --git a/modules/l0/DependencyControl/ConfigHandler.moon b/modules/l0/DependencyControl/ConfigHandler.moon new file mode 100644 index 0000000..eb2fdac --- /dev/null +++ b/modules/l0/DependencyControl/ConfigHandler.moon @@ -0,0 +1,464 @@ +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" +Common = require "l0.DependencyControl.Common" +JsonSchema = require "l0.DependencyControl.JsonSchema" + +---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)..." + migrationSaveFailed: "Migrated config '%s' to the current schema, but couldn't save it (%s); will retry on next load." + } + 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). + ---@param schemaOpts? { schemaId: string, migrate: fun(config: table, current?: string, target: string): boolean } Schema id this handler targets and the migration callback run when a loaded file's `$schema` differs. Applied on first creation; a cached handler keeps the opts it was created with. + ---@return ConfigHandler? handler + ---@return string? err + @get = (filePath, logger = @logger, noLoad = false, schemaOpts) => + -- normalize first, then look up by the canonical path: the cache is keyed by the validated path, + -- so comparing against the raw filePath would miss and construct a duplicate handler for one file + path, msg = fileOps.validateFullPath filePath, true + return nil, msgs.new.badPath\format filePath, msg unless path + + return @@handlers[path] if @@handlers[path] + + success, handler = pcall ConfigHandler, path, logger, schemaOpts + 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 + ---@param schemaOpts? { schemaId: string, migrate: fun(config: table, current?: string, target: string): boolean } The `$schema` this handler targets and the migration callback invoked on load when a file's `$schema` differs. + new: (filePath, @logger = Logger(fileBaseName: @@__name), schemaOpts = {}) => + @views = setmetatable {}, {__mode: 'k'} + @config = {} + -- the loaded file's `$schema`, exposed so views can see which schema their values conform to + @schemaId = nil + @__targetSchemaId = schemaOpts.schemaId + @__migrate = schemaOpts.migrate + 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 + ---@private + __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 + -- bring a pre-target config up to the handler's schema, then persist the one-time change + migrated = false + if @__migrate and @__targetSchemaId + currentSchema = config[JsonSchema.JSON_SCHEMA_ID_KEYWORD] + if currentSchema != @__targetSchemaId and @.__migrate config, currentSchema, @__targetSchemaId + config[JsonSchema.JSON_SCHEMA_ID_KEYWORD] = @__targetSchemaId + migrated = true + @schemaId = config[JsonSchema.JSON_SCHEMA_ID_KEYWORD] + + @config = config + view\refresh! for view, _ in pairs @views + + if migrated + ok, msg = @save! + @logger\warn msgs.load.migrationSaveFailed, @filePath, msg unless ok + return true + + viewsToRefresh = Common.makeSet 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 + + Common.makeSet @__getOverlappingViews(view), viewsToRefresh, false + + 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 under the lock we already hold (useLock false, or readFile would release it) + config, err = readFile @, waitLockTime, false + 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..85e134c --- /dev/null +++ b/modules/l0/DependencyControl/ConfigView.moon @@ -0,0 +1,233 @@ +Common = require "l0.DependencyControl.Common" +local ConfigHandler + +---A read/write view over one section key of a view's user config. A read serves the user-set value, +---falling through to the section default when unset. A write lands only in the user section (created on +---first write), so defaults are never materialized into stored config. The view's user config is read +---live, so a held section view survives a refresh or load replacing that table. +---@param view ConfigView The view whose user config backs the section. +---@param sectionKey string The section's top-level key within the view's hive. +---@param defaultSection table The section's defaults, read for keys the user section doesn't set. +---@return table sectionView The merged read/write section view; pairs yields the defaults overlaid with the user-set keys. +mergeSection = (view, sectionKey, defaultSection) -> + setmetatable {}, { + __index: (_, k) -> + userSection = view.userConfig[sectionKey] + v = userSection and userSection[k] + if v != nil then v else defaultSection[k] + __newindex: (_, k, v) -> + userSection = view.userConfig[sectionKey] + unless userSection + userSection = {} + view.userConfig[sectionKey] = userSection + userSection[k] = v + __len: -> 0 + __ipairs: -> error "numerically indexed config hive keys are not supported" + __pairs: -> + merged = {} + merged[k] = v for k, v in pairs defaultSection + if userSection = view.userConfig[sectionKey] + merged[k] = v for k, v in pairs userSection + return next, merged + } + +---A view into a hive (nested path) of a ConfigHandler's JSON config file. +---Holds the defaults-fallthrough 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). + ---@param schemaOpts? { schemaId: string, migrate: fun(config: table, current?: string, target: string): boolean } Schema id/migration for the backing handler (see ConfigHandler.get); applied only when the handler is first created for this file. + ---@return ConfigView? view + ---@return string? err + @get = (filePath, hivePath, defaults, logger, noLoad = false, schemaOpts) => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + + if filePath + handler, msg = ConfigHandler\get filePath, logger, noLoad, schemaOpts + return nil, msg unless handler + return handler\getView hivePath, defaults + else + -- in-memory view with 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 + -- compatibility alias exposing the config file path 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 + + @defaults = defaults and Common.deepCopy(defaults) or {} + @config = setmetatable {}, { + __index: (_, k) -> + uc = @userConfig[k] + def = @defaults[k] + return mergeSection @, k, def if type(def) == "table" and (uc == nil or type(uc) == "table") + return def if uc == nil + return uc + __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 + + + ---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 = Common.makeSet 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 Use `save`. Retained as an alias for callers written against DepCtrl < 0.7. + ---@param waitLockTime? number Seconds to wait for the config lock. + ---@return boolean? success + ---@return string? err + write: (waitLockTime) => @save waitLockTime + + ---Attaches this view to a different config file, without loading it (the caller loads separately). + ---@param filePath string Full path to the config file. + ---@return boolean? success + ---@return string? err + setFile: (filePath) => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + oldHandler = @__configHandler + handler, msg = ConfigHandler\get filePath, (oldHandler and oldHandler.logger), true -- noLoad: caller loads separately + return nil, msg unless handler + oldHandler.views[@] = nil if oldHandler -- detach from the previous handler + @__configHandler = handler + handler.views[@] = true -- register so the handler's whole-file refreshes reach this view + @file = handler.filePath + return true + + ---Detaches this view from its config file, reverting to an in-memory (orphan) state. + ---@return boolean success + unsetFile: => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + oldHandler = @__configHandler + oldHandler.views[@] = nil if oldHandler -- detach from the previous handler + @__configHandler = ConfigHandler nil, oldHandler and oldHandler.logger + @__configHandler.views[@] = true -- register with the fresh in-memory handler + @file = nil + @userConfig = {} + return true + + ---Returns a ConfigView for another hive of the same config file, sharing this view's handler. + ---@param hivePath string|string[] The hive path within the config file (from the file root, not relative to this view). + ---@param defaults? table Default values for the returned view's hive. + ---@param noLoad? boolean Skip loading the returned view (the caller loads it separately). + ---@return ConfigView? view + ---@return string? err + 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..5a6ecb2 --- /dev/null +++ b/modules/l0/DependencyControl/Constants.moon @@ -0,0 +1,7 @@ +{ + DEPCTRL_NAME: "DependencyControl" + DEPCTRL_SHORT_NAME: "DepCtrl" + DEPCTRL_NAMESPACE: "l0.DependencyControl" + DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX: "__depCtrl" + DEPCTRL_FEED_URL: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json" +} diff --git a/modules/l0/DependencyControl/Crypto.moon b/modules/l0/DependencyControl/Crypto.moon new file mode 100644 index 0000000..413c560 --- /dev/null +++ b/modules/l0/DependencyControl/Crypto.moon @@ -0,0 +1,184 @@ +-- 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 + -- on Linux and other Unix, use OpenSSL's 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]" + + -- use the non-deprecated EVP interface where available (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)" + + -- on very old libcrypto, fall back to the legacy one-shot SHA1, deprecated in 3.0 + -- but still exported and resolvable by FFI at runtime + 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 + + ---Computes a SHA-1 digest entirely in Lua; the fallback backend when no native SHA-1 is available. + ---@private + ---@param msg string The input bytes to hash (assumed to be a string; not validated). + ---@return string digest A 40-character lowercase hex digest. + @_sha1Lua = sha1Lua + +return Crypto diff --git a/modules/l0/DependencyControl/Downloader.moon b/modules/l0/DependencyControl/Downloader.moon new file mode 100644 index 0000000..edecdd9 --- /dev/null +++ b/modules/l0/DependencyControl/Downloader.moon @@ -0,0 +1,643 @@ +-- 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" +Host = require "l0.DependencyControl.Host" +Accessors = require "l0.DependencyControl.Accessors" + +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." + privateHostBlocked: "Refusing to download from a private, loopback, or link-local address as an SSRF safeguard (a feed or package could otherwise point DependencyControl at an internal host): %s. To allow this for local development/testing, set updates.blockPrivateHosts = false in the DependencyControl config." + nonHttpScheme: "Refusing to download from a non-http(s) URL as an SSRF safeguard (a feed or package could otherwise point DependencyControl at a local file or other scheme): %s." + tooManyRedirects: "Gave up following redirects while downloading '%s' (too many hops)." + redirectNoLocation: "Redirect response from '%s' had no Location header." +} + +-- Resolves a redirect Location (which may be relative) against the URL it came from. An absolute URL is +-- returned unchanged; "//host/path" inherits the base's scheme; "/path" keeps the base authority; anything +-- else is resolved against the base's directory. Used by the WinINet backend, which follows redirects by +-- hand (libcurl resolves them internally). +resolveRedirect = (base, location) -> + return location if location\match "^%a[%w+.%-]*://" + proto, authority, path = base\match "^(%a[%w+.%-]*)://([^/]*)(.*)$" + return location unless proto + return "#{proto}:#{location}" if location\sub(1, 2) == "//" + return "#{proto}://#{authority}#{location}" if location\sub(1, 1) == "/" + dir = path\match("^(.*/)") or "/" + "#{proto}://#{authority}#{dir}#{location}" + +-- 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 + -- 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_MAXREDIRS = 68 -- cap how many redirects are followed + CURLOPT_PROTOCOLS = 181 -- bitmask of protocols the transfer may use + CURLOPT_REDIR_PROTOCOLS = 182 -- bitmask of protocols a redirect may switch to + CURLPROTO_HTTP = 1 + CURLPROTO_HTTPS = 2 + CURLOPT_PREREQFUNCTION = 20312 -- called before each connection (incl. every redirect hop); may abort it + CURL_PREREQFUNC_OK, CURL_PREREQFUNC_ABORT = 0, 1 + 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 + + -- Fires before each connection (including every redirect hop), so a redirect to a private/internal + -- host is refused even though libcurl follows redirects itself. Requires curl >= 7.80, otherwise ignored. + pcall ffi.cdef, "typedef int (*dc_curl_prereq_cb)(void* clientp, char* conn_primary_ip, char* conn_local_ip, int conn_primary_port, int conn_local_port);" + prereqBlockPrivate = ffi.cast "dc_curl_prereq_cb", (_, primaryIp) -> + ok, private = pcall -> Host(ffi.string primaryIp).isPrivate + ok and private and CURL_PREREQFUNC_ABORT or CURL_PREREQFUNC_OK + + 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_MAXREDIRS, 10 -- bound redirect fan-out + -- confine the transfer and any redirect to http(s), so a redirect can't reach file:// etc. + if manager.blockPrivateHosts + httpOnly = bit.bor CURLPROTO_HTTP, CURLPROTO_HTTPS + setLong handle, CURLOPT_PROTOCOLS, httpOnly + setLong handle, CURLOPT_REDIR_PROTOCOLS, httpOnly + curl.curl_easy_setopt handle, CURLOPT_PREREQFUNCTION, prereqBlockPrivate + 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 HttpQueryInfoA(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_FLAG_NO_AUTO_REDIRECT = 0x00200000 -- don't auto-follow redirects; we validate each hop ourselves + 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_LOCATION = 33 -- the Location response header (a redirect's target URL) + HTTP_QUERY_FLAG_NUMBER = 0x20000000 -- return the queried information as a number instead of a string (e.g. for status code or content length) + MAX_REDIRECTS = 10 -- cap on redirect hops we follow before giving up + 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 + + -- Reads a response header as a string (ANSI). A fixed buffer is plenty for a Location URL; the + -- private-host check tolerates a truncated value (it just fails to parse and refuses/loads elsewhere). + queryString = (request, info) -> + buf = ffi.new "char[2048]" + len = ffi.new "unsigned long[1]", 2048 + ok = winInet.HttpQueryInfoA request, info, buf, len, nil + ok != 0 and ffi.string(buf, len[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 = (manager, 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 + + -- Follow redirects by hand (NO_AUTO_REDIRECT) so we can validate each hop's host: WinINet + -- would otherwise re-resolve and connect to a redirect target with no hook to inspect it. + fail = (msg) -> + outFileHandle\close! + false, msg + url, redirectsLeft, request = dl.url, MAX_REDIRECTS, nil + while true + if manager.blockPrivateHosts + return fail msgs.nonHttpScheme\format url unless url\match "^https?://" + host = Host.fromUrl url + return fail msgs.privateHostBlocked\format url if host and host.isPrivate + + request = winInet.InternetOpenUrlW session, toWide(url), nil, 0, + bit.bor(INTERNET_FLAG_RELOAD, INTERNET_FLAG_NO_CACHE_WRITE, INTERNET_FLAG_NO_AUTO_REDIRECT), 0 + return fail msgs.openUrlFailed\format url if request == nil + + status = queryNumber request, HTTP_QUERY_STATUS_CODE + if status and status >= 300 and status < 400 + location = queryString request, HTTP_QUERY_LOCATION + winInet.InternetCloseHandle request + request = nil + return fail msgs.tooManyRedirects\format dl.url if redirectsLeft <= 0 + return fail msgs.redirectNoLocation\format url unless location + url, redirectsLeft = resolveRedirect(url, location), redirectsLeft - 1 + elseif status and status >= 400 + winInet.InternetCloseHandle request + return fail msgs.httpStatus\format status + else + break + + 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, 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 + + ---Part of the download-runner callback contract: a runner calls this to fire this download's + ---Progress listeners as bytes arrive. + _notifyProgress: => @_emit @@Event.Progress + + ---Part of the download-runner callback contract: a runner calls this when the transfer ends to + ---finalize it (success, or a transport-level error) and fire Finish listeners (which may downgrade + ---the status via `markFailed`). Idempotent — only the first call takes effect. + ---@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 + + ---Part of the download-runner callback contract: a runner calls this to finalize the download as + ---cancelled and fire Finish listeners. Idempotent. + _cancel: => + return if @_finalized + @_finalized = true + @status = @@Status.Cancelled + @_emit @@Event.Finish + + +---Options accepted by the Downloader constructor. +---@class DownloaderOptions +---@field stallTimeout? number Seconds a transfer may receive no data before it's aborted; 0/false disables stall detection. +---@field maxConnections? integer Maximum simultaneous transfers (also the per-server connection limit); excess transfers queue. +---@field blockPrivateHosts? boolean Refuse URLs whose host is a private/loopback/link-local address (an SSRF guard). + +---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 +---@field progress number Current aggregate download progress across all queued transfers, 0-100 (read-only). +class Downloader extends EventEmitter + @Download = Download + @Event = Enum "DownloaderEvent", { Progress: "progress", Finished: "finished" } + ---Drives every queued download of the given downloader to completion with a round-robin + ---scheduler, keeping up to its `maxConnections` transfers active and honoring its cancellation + ---and stall-timeout settings. Blocks until all transfers finish or are cancelled. + ---@param manager Downloader The downloader whose queued downloads are transferred. + ---@param driver table The transfer backend, providing `start(dl)`, `step(dl)`, `finish(dl)`, and an optional `shutdown()`. + @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? DownloaderOptions Optional downloader settings. + new: (runner, options = {}) => + super! + + @stallTimeout = options.stallTimeout if options.stallTimeout != nil + @maxConnections = options.maxConnections if options.maxConnections != nil + @blockPrivateHosts = options.blockPrivateHosts if options.blockPrivateHosts != 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) + + -- as an opt-in per-instance SSRF guard, confine fetches to http(s) and reject private/internal hosts + -- The scheme check also covers URLs with no host (file://, ...) that the private-IP check can't see. + if @blockPrivateHosts + return nil, msgs.nonHttpScheme\format url unless url\match "^https?://" + host = Host.fromUrl url + return nil, msgs.privateHostBlocked\format url if host and host.isPrivate + + 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 @ + + progress: Accessors.property + get: => computeProgress @downloads + + ---Part of the download-runner callback contract: a runner calls this to report the manager's + ---aggregate progress and fire the Downloader's Progress listeners. + ---@param percent number Aggregate progress, 0-100. + _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! + +Accessors.install Downloader +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +return UnitTestSuite\withTestExports Downloader, {:resolveRedirect} diff --git a/modules/l0/DependencyControl/Enum.moon b/modules/l0/DependencyControl/Enum.moon new file mode 100644 index 0000000..1ea5733 --- /dev/null +++ b/modules/l0/DependencyControl/Enum.moon @@ -0,0 +1,122 @@ +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" + ---Reports whether `k` is reserved as an enum key — a built-in member name or `__`-prefixed. + ---@param k string + ---@return boolean reserved + @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 Logger for enum error messages (default: the class logger). + new: (@name, values, @__logger = @@logger) => + @__logger\assert type(@name) == "string", msgs.new.missingOrInvalidName, Logger\describeType @name + @elements, @__keysByValue, @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 @__keysByValue[v] == nil, msgs.new.valueAlreadyTaken, k, @name, v, @__keysByValue[v] + + @elements[k], @__keysByValue[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 a human-readable description of the given value(s) in this enum. + ---@param values? any A single value, or a list of values to look up. If not provided, returns all keys. + ---@param pattern? fun(key: string, value: any): string A function to format the key/value pair for display (default "<value> (<key>)"). + ---@param join? string|boolean Separator string for joining multiple keys, true for ", ", or false to return a list (default true). + ---@return string|string[] result A single string when joining, or a list of the formatted keys when join is false. + describe: (values = @values, pattern = ((key, value) -> "#{value} (#{key})"), join = true) => + values = {values} if "table" != type values + + keys = for v in *values + key = @__keysByValue[v] + @__logger\assert key != nil, msgs.describe.valueNotDefined, v, @name + pattern key, v + + 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 @__keysByValue[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..afad70e --- /dev/null +++ b/modules/l0/DependencyControl/EventEmitter.moon @@ -0,0 +1,41 @@ +---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 + ---Creates an event emitter with an empty listener registry. + 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 every listener registered for an event, passing the emitter instance followed by the + ---given arguments. A listener may safely register or remove listeners during dispatch. + ---@param event any The event value whose listeners to invoke. + ---@param ... any Extra arguments forwarded to each listener after the emitter instance. + _emit: (event, ...) => + listeners = @_listeners[event] + return unless listeners + cb @, ... for cb in *[l for l in *listeners] diff --git a/modules/l0/DependencyControl/FeedInventory.moon b/modules/l0/DependencyControl/FeedInventory.moon new file mode 100644 index 0000000..2fd6e9f --- /dev/null +++ b/modules/l0/DependencyControl/FeedInventory.moon @@ -0,0 +1,327 @@ +constants = require "l0.DependencyControl.Constants" +Common = require "l0.DependencyControl.Common" +Enum = require "l0.DependencyControl.Enum" + +local UpdateTask + +---How a feed was discovered — the source it came from. A feed can have several. +---@alias FeedProvenance +---| "official-depctrl" # OfficialDepCtrl: DependencyControl's own feed +---| "official-known" # OfficialKnown: a feed advertised in DependencyControl's own feed's knownFeeds +---| "user-extra" # UserExtra: a feed in the user's extraFeeds (a discovery root) +---| "package-declared" # PackageDeclared: the feed an installed package declares for its own updates +---| "package-override" # PackageOverride: an installed package's per-package userFeed override +---| "dependency-advertised" # DependencyAdvertised: a feed advertised in an installed package's requiredModules +---| "transitive-known" # TransitiveKnown: a feed advertised in another fetched feed's knownFeeds (crawl only) +Provenance = Enum "FeedProvenance", { + OfficialDepCtrl: "official-depctrl" + OfficialKnown: "official-known" + UserExtra: "user-extra" + PackageDeclared: "package-declared" + PackageOverride: "package-override" + DependencyAdvertised: "dependency-advertised" + TransitiveKnown: "transitive-known" +} + +-- Display order for a feed's provenance, most authoritative origin first. Pinned here because Enum.values +-- follows hash order, not definition order. +provenanceOrder = { + Provenance.OfficialDepCtrl + Provenance.OfficialKnown + Provenance.UserExtra + Provenance.PackageDeclared + Provenance.PackageOverride + Provenance.DependencyAdvertised + Provenance.TransitiveKnown +} + +---Which crawl budget a truncation hit. +---@alias FeedCrawlLimit +---| "per-feed" # PerFeed: the cap on how many untrusted feeds one feed may contribute +---| "per-root" # PerRoot: the per-subtree budget for untrusted expansion +---| "depth" # Depth: the crawl-depth limit; a feed at this depth is left unfetched +CrawlLimit = Enum "FeedCrawlLimit", { + PerFeed: "per-feed" + PerRoot: "per-root" + Depth: "depth" +} + +-- Ensures inventoryEntriesByUrl has a (possibly blank) entry for url; returns it, or nil for an invalid url. +ensureInventoryEntry = (inventoryEntriesByUrl, url) -> + return nil unless type(url) == "string" and #url > 0 + entry = inventoryEntriesByUrl[url] + unless entry + entry = {:url, provenance: {}, packages: {}, advertisedBy: {}} + inventoryEntriesByUrl[url] = entry + entry + +-- Ensures inventoryEntriesByUrl has an entry for url and tags it with the given provenance source; returns the entry (nil for +-- an invalid url). The `packages`/`advertisedBy` sets are filled by the caller. +addSource = (inventoryEntriesByUrl, url, source) -> + entry = ensureInventoryEntry inventoryEntriesByUrl, url + entry.provenance[source] = true if entry + entry + +-- The sorted keys of a set, as a list. +getSortedKeys = (set) -> + keys = [k for k in pairs set] + table.sort keys + keys + +-- A shallow copy of `list` with `item` appended, leaving the original untouched (for per-node crawl routes). +appended = (list, item) -> + copy = [x for x in *list] + copy[#copy + 1] = item + copy + +-- Cap on how many dropped feed URLs one truncation event records; its `dropped` count stays exact. +maxDropSample = 50 + +-- The feed a package's persisted `currentSource` resolves to (nil when absent), via UpdateTask's shared resolver. +resolveCurrentSource = (pkg, modulesSection) -> + src = pkg.currentSource + return nil unless type(src) == "table" + + UpdateTask or= require "l0.DependencyControl.UpdateTask" + UpdateTask.resolveSourceUrl src, pkg.feed, pkg.userFeed, modulesSection + +---A reachable feed with the sources it was discovered through and its trust status. +---@class FeedInventoryEntry +---@field url string The feed URL. +---@field provenance FeedProvenance[] The sources this feed was found through, in a stable order (empty for a feed present only in `trustedFeeds`). +---@field packages string[] Sorted namespaces of installed packages that declare, override to, or advertise this feed (empty when none). +---@field advertisedBy string[] Sorted URLs of fetched feeds that list this feed in their knownFeeds (crawl only). +---@field trustStatus FeedTrustStatus The feed's trust status. +---@field blockedBy? BlockedFeedEntry The block entry matching this feed when `trustStatus` is blocked (carries the reason and official/user flag). +---@field fetched? boolean True when `crawl` successfully read this feed; nil for `gather` (offline) or a feed the crawl couldn't reach. +---@field lastFetchedAt? integer Unix time this feed was last successfully fetched into the persistent cache; nil if it was never cached. +---@field inUse? boolean True when this feed is the effective update source (override else declared feed) of an installed package. +---@field inTrustedFeeds? boolean True when the feed is in the user's `trustedFeeds` — a trust-only listing that grants trust without acting as a discovery source. + +---The crawl budgets, keyed by `FeedCrawlLimit`; a missing key falls back to a built-in default. +---@alias FeedCrawlLimits table<FeedCrawlLimit, integer> + +---A budget the crawl hit, with enough context to act on it (block a feed, file a report). +---@class FeedCrawlTruncation +---@field limit FeedCrawlLimit Which budget was hit. +---@field limitValue integer The configured value of that budget. +---@field feed string The feed being processed when the limit hit (the advertiser for per-feed/per-root, the unfetched feed itself for depth). +---@field root string The config-derived feed whose subtree this occurred in. +---@field depth integer The crawl depth of `feed` (0 for a config-derived root). +---@field route string[] The feed URLs from `root` to `feed` inclusive — how the crawl reached it. +---@field dropped integer How many advertised feeds this event left unexplored (0 for depth, whose single unfetched feed is `feed`). +---@field droppedUrls string[] The dropped feed URLs, capped to a sample when `dropped` exceeds it; empty for depth. + +---What a crawl explored and where it stopped short. +---@class FeedCrawlStats +---@field fetched integer How many feeds had their `knownFeeds` read. +---@field truncated boolean Whether any budget was hit (a quick "results incomplete?" check). +---@field truncations FeedCrawlTruncation[] Each budget hit, in crawl order, for surfacing or acting on. + +---Enumerates the feeds DependencyControl knows about — from the user config, installed packages, and its +---official trust lists — each tagged with the sources it was found through and its trust status, for the Manage +---Feeds UI. `gather` is network-free; `crawl` additionally discovers transitively-advertised feeds through an +---injected feed loader, bounding untrusted expansion. +---@class FeedInventory +class FeedInventory + @Provenance = Provenance + @CrawlLimit = CrawlLimit + + ---The built-in crawl budgets, used for any budget the config doesn't set (and the config's own default). + ---@type FeedCrawlLimits + @defaultCrawlLimits = { + [CrawlLimit.Depth]: 7 + [CrawlLimit.PerRoot]: 50 + [CrawlLimit.PerFeed]: 25 + } + + ---@param config ConfigView The global config view; its `c` holds `extraFeeds`/`trustedFeeds`/`macros`/`modules`, `fetchUntrustedFeeds`, and `feedCrawlLimits`. + ---@param feedTrust FeedTrust The trust model, for the official feeds and trust/block queries. + ---@param feedLoader FeedLoader Loads feeds during a crawl and holds the feed cache read for last-fetch times. + new: (@config, @feedTrust, @feedLoader) => + + ---The feed a package effectively updates from: its remembered `currentSource` (resolved), falling back to + ---its override (`userFeed`) or declared `feed`. + ---@param pkg table An installed package's config entry. + ---@param modulesSection table The modules config section, for resolving a provider `currentSource`. + ---@return string? url The feed the package updates from, or nil when it declares none. + @getEffectiveSource = (pkg, modulesSection) -> + resolveCurrentSource(pkg, modulesSection) or pkg.userFeed or pkg.feed + + ---Collects the feeds reachable from config, installed packages, and the official trust lists into a + ---`url -> raw entry` map (provenance/packages/advertisedBy still as sets). Network-free. + ---@private + ---@return table<string, FeedInventoryEntry> inventoryEntriesByUrl + __collectConfigFeeds: => + inventoryEntriesByUrl = {} + tagPackage = (url, source, namespace) -> + entry = addSource inventoryEntriesByUrl, url, source + entry.packages[namespace] = true if entry + + for url in pairs @feedTrust\getOfficialTrustedFeeds! + addSource inventoryEntriesByUrl, url, url == constants.DEPCTRL_FEED_URL and Provenance.OfficialDepCtrl or Provenance.OfficialKnown + + c = @config.c + addSource inventoryEntriesByUrl, url, Provenance.UserExtra for url in *(c.feeds.extraFeeds or {}) + -- trustedFeeds grant trust but aren't a discovery source, so they get no provenance — just the flag + for url in *(c.feeds.trustedFeeds or {}) + entry = ensureInventoryEntry inventoryEntriesByUrl, url + entry.inTrustedFeeds = true if entry + + modulesSection = c[Common.ScriptTypeSection[Common.ScriptType.Module]] or {} + for scriptType in *Common.ScriptType.values + for namespace, pkg in pairs (c[Common.ScriptTypeSection[scriptType]] or {}) + continue unless type(pkg) == "table" + tagPackage pkg.feed, Provenance.PackageDeclared, namespace + tagPackage pkg.userFeed, Provenance.PackageOverride, namespace + for dep in *(pkg.requiredModules or {}) + tagPackage dep.feed, Provenance.DependencyAdvertised, namespace if type(dep) == "table" + effective = FeedInventory.getEffectiveSource pkg, modulesSection + inventoryEntriesByUrl[effective].inUse = true if type(effective) == "string" and inventoryEntriesByUrl[effective] + + return inventoryEntriesByUrl + + ---Collapses each raw entry's provenance/package/advertisedBy sets to stable-ordered lists, attaches the + ---trust status (and, for a blocked feed, the block entry that matches it), and stamps the feed's last + ---fetch time from the persistent cache. + ---@private + ---@param inventoryEntriesByUrl table<string, FeedInventoryEntry> The raw entries to finalize. + ---@return FeedInventoryEntry[] feeds + __finalize: (inventoryEntriesByUrl) => + cache = @feedLoader.cache + feeds = {} + for url, entry in pairs inventoryEntriesByUrl + entry.provenance = [p for p in *provenanceOrder when entry.provenance[p]] + entry.packages = getSortedKeys entry.packages + entry.advertisedBy = getSortedKeys entry.advertisedBy + entry.trustStatus, entry.blockedBy = @feedTrust\getTrustStatus url + meta = cache\getMeta url + entry.lastFetchedAt = meta.cachedAt if meta and meta.cachedAt + feeds[#feeds + 1] = entry + feeds + + ---Gathers the known feeds from config, installed packages, and the official trust lists. Fetches nothing. + ---@return FeedInventoryEntry[] feeds + gather: => @__finalize @__collectConfigFeeds! + + ---Returns the namespaces of installed packages that effectively update from the given feed URL. + ---@param feedUrl string The feed URL to check. + ---@return string[] namespaces Sorted namespaces whose effective source is that feed. + getPackagesSourcedFrom: (feedUrl) => + c = @config.c + modulesSection = c[Common.ScriptTypeSection[Common.ScriptType.Module]] or {} + matched = {} + for scriptType in *Common.ScriptType.values + for namespace, pkg in pairs (c[Common.ScriptTypeSection[scriptType]] or {}) + continue unless type(pkg) == "table" + matched[#matched + 1] = namespace if FeedInventory.getEffectiveSource(pkg, modulesSection) == feedUrl + table.sort matched + matched + + ---Loads a feed's `knownFeeds` URLs through the shared feed loader, or nil when the feed can't be loaded. + ---@private + ---@param url string The feed URL to read. + ---@return string[]? knownFeeds + __loadKnownFeeds: (url) => + ok, feed = pcall @feedLoader.load, @feedLoader, url + ok and feed and feed.data and feed\getKnownFeeds! or nil + + ---Fetches feeds and discovers transitively-advertised ones by crawling the `knownFeeds` graph out from the + ---config-derived feeds; untrusted expansion is bounded, so check `stats.truncated` for incomplete results. + ---@return FeedInventoryEntry[] feeds The known feeds, enriched with what the crawl discovered. + ---@return FeedCrawlStats stats What the crawl explored and where it stopped short. + crawl: => + inventoryEntriesByUrl = @__collectConfigFeeds! + stats = @__crawlKnownFeeds inventoryEntriesByUrl + return @__finalize(inventoryEntriesByUrl), stats + + ---Breadth-first crawl of the `knownFeeds` graph, extending inventoryEntriesByUrl in place with the transitively-discovered + ---feeds under the untrusted-expansion bounds. Each config-derived feed is its own budget subtree, so a + ---malicious subtree can't starve the others. + ---@private + ---@param inventoryEntriesByUrl table<string, FeedInventoryEntry> The config-derived feeds to start from; extended in place. + ---@return FeedCrawlStats stats + __crawlKnownFeeds: (inventoryEntriesByUrl) => + c = @config.c + limits = c.feeds.crawlLimits or {} + defaults = @@defaultCrawlLimits + maxDepth = limits[CrawlLimit.Depth] or defaults[CrawlLimit.Depth] + maxPerRoot = limits[CrawlLimit.PerRoot] or defaults[CrawlLimit.PerRoot] + maxPerFeed = limits[CrawlLimit.PerFeed] or defaults[CrawlLimit.PerFeed] + stats = {fetched: 0, truncated: false, truncations: {}} + + record = (limit, feedUrl, root, depth, route, limitValue, drops) -> + stats.truncated = true + stats.truncations[#stats.truncations + 1] = { + :limit, feed: feedUrl, :root, :depth, :route, :limitValue + dropped: drops and drops.count or 0 + droppedUrls: drops and drops.sample or {} + } + + -- accumulate dropped feed URLs into a bounded sample while keeping an exact count + newDrops = -> {count: 0, sample: {}} + drop = (drops, url) -> + drops.count += 1 + drops.sample[#drops.sample + 1] = url if #drops.sample < maxDropSample + + -- BFS frontier seeded with the config-derived feeds; each is its own subtree root for budgeting + queue, visited, untrustedPerRoot = {}, {}, {} + for url, entry in pairs inventoryEntriesByUrl + -- don't crawl feeds with no discovery provenance (e.g. orphaned `trustedFeeds` entries) + continue unless next entry.provenance + -- gate the root itself the same way its advertised children are: a blocked root is never + -- fetched, and an untrusted root honors the fetch policy (recorded above, just not crawled) + continue unless @feedTrust\shouldFetch url + queue[#queue + 1] = {:url, root: url, depth: 0, route: {url}} + visited[url] = true + + head = 1 + while head <= #queue + {url: feedUrl, :root, :depth, :route} = queue[head] + head += 1 + if depth >= maxDepth + record CrawlLimit.Depth, feedUrl, root, depth, route, maxDepth + continue + knownFeeds = @__loadKnownFeeds feedUrl + continue unless knownFeeds + stats.fetched += 1 + inventoryEntriesByUrl[feedUrl].fetched = true + + perFeedUntrusted = 0 + perFeedDrops, perRootDrops = newDrops!, newDrops! + for knownUrl in *knownFeeds + continue unless type(knownUrl) == "string" and #knownUrl > 0 + continue if @feedTrust\isBlocked knownUrl + trusted = @feedTrust\isTrusted knownUrl + unless trusted + -- bound how many untrusted feeds a single feed may contribute + if perFeedUntrusted >= maxPerFeed + drop perFeedDrops, knownUrl + continue + perFeedUntrusted += 1 + + -- a feed advertised by DepCtrl's own feed is official-known, not merely transitively known + prov = feedUrl == constants.DEPCTRL_FEED_URL and Provenance.OfficialKnown or Provenance.TransitiveKnown + entry = addSource inventoryEntriesByUrl, knownUrl, prov + entry.advertisedBy[feedUrl] = true + continue if visited[knownUrl] + + if trusted + visited[knownUrl] = true + queue[#queue + 1] = {url: knownUrl, :root, depth: depth + 1, route: appended(route, knownUrl)} + -- for an untrusted feed, within the per-root budget, ask the fetch policy before crawling in. shouldFetch + -- prompts under the `prompt` policy (session-cached), and denies under `never` or with no + -- prompter (headless), leaving the feed recorded above but not followed. + else + if (untrustedPerRoot[root] or 0) >= maxPerRoot + drop perRootDrops, knownUrl + elseif @feedTrust\shouldFetch knownUrl + untrustedPerRoot[root] = (untrustedPerRoot[root] or 0) + 1 + visited[knownUrl] = true + queue[#queue + 1] = {url: knownUrl, :root, depth: depth + 1, route: appended(route, knownUrl)} + + record CrawlLimit.PerFeed, feedUrl, root, depth, route, maxPerFeed, perFeedDrops if perFeedDrops.count > 0 + record CrawlLimit.PerRoot, feedUrl, root, depth, route, maxPerRoot, perRootDrops if perRootDrops.count > 0 + stats + +return FeedInventory diff --git a/modules/l0/DependencyControl/FeedLoader.moon b/modules/l0/DependencyControl/FeedLoader.moon new file mode 100644 index 0000000..5e32c8e --- /dev/null +++ b/modules/l0/DependencyControl/FeedLoader.moon @@ -0,0 +1,32 @@ +FileCache = require "l0.DependencyControl.FileCache" +constants = require "l0.DependencyControl.Constants" +Logger = require "l0.DependencyControl.Logger" +UpdateFeed = require "l0.DependencyControl.UpdateFeed" + +defaultLogger = Logger fileBaseName: "DepCtrl.FeedLoader" + +---Owns DependencyControl's single on-disk feed cache and hands out `UpdateFeed` instances wired to it, +---so no consumer assembles feed-fetch settings or names the cache. One instance is built per config (the +---updater keeps the shared one) and injected into every feed consumer; its cache is reachable as `.cache`. +---@class FeedLoader +---@field cache FileCache The shared feed cache, for metadata reads such as a feed's last-fetch time. +class FeedLoader + ---Reads the feed-fetch settings once and opens the shared feed cache under DepCtrl's namespace, wiring its + ---L1 layer to `UpdateFeed`'s decoder so a cache hit serves a ready-parsed feed base. + ---@param config ConfigView The global config view; reads `paths.cache`, `feeds.cacheMaxAge` and `updates.blockPrivateHosts`. + ---@param logger? Logger Logger for the cache and the feeds it loads. + new: (config, @logger = defaultLogger) => + c = config.c + @cache = FileCache.get c.paths.cache, constants.DEPCTRL_NAMESPACE, "feeds", + {maxAge: c.feeds.cacheMaxAge, logger: @logger, deserialize: UpdateFeed.deserialize} + @blockPrivateHosts = c.updates.blockPrivateHosts + + ---Builds an `UpdateFeed` for the given URL, injecting the shared cache and the configured fetch policy. + ---@param url string The feed URL to load. + ---@param opts? { autoLoad?: boolean } `autoLoad` fetches immediately (default true). To refresh an entire update pass, expire the cache with `FileCache.expireAll`. + ---@return UpdateFeed feed + load: (url, opts = {}) => + autoLoad = opts.autoLoad + autoLoad = true if autoLoad == nil + feedConfig = {cache: @cache, blockPrivateHosts: @blockPrivateHosts} + UpdateFeed url, autoLoad, nil, feedConfig, @logger diff --git a/modules/l0/DependencyControl/FeedManager.moon b/modules/l0/DependencyControl/FeedManager.moon new file mode 100644 index 0000000..06e157e --- /dev/null +++ b/modules/l0/DependencyControl/FeedManager.moon @@ -0,0 +1,130 @@ +constants = require "l0.DependencyControl.Constants" +Common = require "l0.DependencyControl.Common" +Crypto = require "l0.DependencyControl.Crypto" +Enum = require "l0.DependencyControl.Enum" +FeedInventory = require "l0.DependencyControl.FeedInventory" +FeedTrust = require "l0.DependencyControl.FeedTrust" + +FeedAction = Enum "FeedAction", { + Trust: "trust" + Block: "block" + Unblock: "unblock" + Remove: "remove" + OpenBrowser: "open-browser" +} + +-- The DepCtrl Browser pre-renders one page per crawled feed under this path, slugged by the feed URL's hash. +browserFeedBase = "https://typesettingtools.github.io/depctrl-browser/feeds/" + +---An action the Manage Feeds UI can offer for a feed. +---@alias FeedAction +---| "trust" # Trust: add the feed to the user's `trustedFeeds` +---| "block" # Block: add a block entry for the feed +---| "unblock" # Unblock: remove the user's exact block on the feed +---| "remove" # Remove: drop the user's `extraFeeds`/`trustedFeeds` contribution +---| "open-browser" # OpenBrowser: open the feed in the DepCtrl Browser + +---A Manage Feeds list row: a feed's inventory fields plus what the UI needs to render and act on it. +---@class FeedManagerRow +---@field url string The feed URL. +---@field trustStatus FeedTrustStatus The feed's trust state. +---@field inTrustedFeeds? boolean True when the feed is in the user's `trustedFeeds` (a trust-only listing). +---@field provenance FeedProvenance[] The sources the feed was found through. +---@field packages string[] Namespaces of installed packages tied to this feed. +---@field advertisedBy string[] Feeds that advertise this feed (crawl only). +---@field blockedBy? BlockedFeedEntry The matching block entry when the feed is blocked. +---@field reachable? boolean Whether a crawl fetched the feed (nil when not crawled). +---@field lastFetchedAt? integer Unix time the feed was last fetched into the persistent cache (nil if never cached). +---@field inUse? boolean Whether the feed is the effective update source of an installed package. +---@field actions FeedAction[] The actions offered for this feed. +---@field browserUrl string The feed's DepCtrl Browser deep-link. +---@field removable boolean Whether the feed has a user contribution that Remove can drop. + +---Turns reachable-feed data into what the Manage Feeds UI shows and offers: the actions available for a feed +---and its DepCtrl Browser link. Trust reads/writes go through `FeedTrust`. +---@class FeedManager +class FeedManager + @FeedAction = FeedAction + + ---@param feedTrust FeedTrust The trust model that action execution delegates to. + new: (@feedTrust) => + + ---The DepCtrl Browser deep-link for a feed. Best-effort: it resolves only for feeds in the browser's crawl + ---graph (official/known feeds). + ---@param feedUrl string The exact feed URL, as stored (the hash is sensitive to casing and trailing slash). + ---@return string url The Browser page URL. + @getBrowserUrl = (feedUrl) -> + "#{browserFeedBase}#{Crypto.sha1(feedUrl)\sub 1, 7}/" + + ---The actions the Manage Feeds UI offers for a feed, from its trust state and provenance. + ---@param entry FeedInventoryEntry The feed to compute the offered actions for. + ---@return FeedAction[] actions In a stable order, state-changing actions first and Open Browser last. + @getAvailableActions = (entry) -> + {:Provenance} = FeedInventory + {:TrustStatus} = FeedTrust + actions = {} + add = (action) -> actions[#actions + 1] = action + + add FeedAction.Trust if entry.trustStatus == TrustStatus.Untrusted + + add FeedAction.Block if entry.url != constants.DEPCTRL_FEED_URL and entry.trustStatus != TrustStatus.Blocked + + blk = entry.blockedBy + add FeedAction.Unblock if entry.trustStatus == TrustStatus.Blocked and blk and not blk.isOfficial and + blk.matchMode == FeedTrust.BlockMatchMode.Exact + + add FeedAction.Remove if Common.listIncludes(entry.provenance, Provenance.UserExtra) or entry.inTrustedFeeds + + add FeedAction.OpenBrowser + actions + + ---Turns feed entries into dialog rows — each feed with its offered actions, Browser link, and `removable` + ---flag. Sorted by URL. + ---@param entries FeedInventoryEntry[] The reachable-feed entries to render (from `FeedInventory.gather`/`crawl`). + ---@return FeedManagerRow[] rows The URL-sorted display rows. + @buildRows = (entries) -> + rows = {} + for entry in *entries + acts = FeedManager.getAvailableActions entry + rows[#rows + 1] = { + url: entry.url + trustStatus: entry.trustStatus + inTrustedFeeds: entry.inTrustedFeeds + provenance: entry.provenance + packages: entry.packages + advertisedBy: entry.advertisedBy + blockedBy: entry.blockedBy + reachable: entry.fetched + lastFetchedAt: entry.lastFetchedAt + inUse: entry.inUse + actions: acts + browserUrl: FeedManager.getBrowserUrl entry.url + removable: Common.listIncludes acts, FeedAction.Remove + } + table.sort rows, (a, b) -> a.url < b.url + rows + + ---Applies a trust action to a feed, delegating to `FeedTrust`. Re-checks that the action is one the feed + ---actually offers, so a guarded action (e.g. blocking the bootstrap feed) is refused even if asked for. + ---Open Browser and any action the feed doesn't offer mutate nothing. + ---@param action FeedAction The action to apply (one offered by `getAvailableActions`). + ---@param entry FeedInventoryEntry The feed to act on. + ---@param opts? { reason?: string } `reason` annotates a Block entry. + ---@return boolean applied Whether trust state was changed. + applyAction: (action, entry, opts = {}) => + return false unless Common.listIncludes @@.getAvailableActions(entry), action + switch action + when FeedAction.Trust + @feedTrust\trust entry.url + when FeedAction.Block + @feedTrust\block entry.url, {matchMode: FeedTrust.BlockMatchMode.Exact, reason: opts.reason} + when FeedAction.Unblock + @feedTrust\unblock entry.url + when FeedAction.Remove + @feedTrust\removeExtraFeed entry.url + @feedTrust\untrust entry.url + else + return false + true + +return FeedManager diff --git a/modules/l0/DependencyControl/FeedTrust.moon b/modules/l0/DependencyControl/FeedTrust.moon new file mode 100644 index 0000000..ea9f2cc --- /dev/null +++ b/modules/l0/DependencyControl/FeedTrust.moon @@ -0,0 +1,372 @@ +constants = require "l0.DependencyControl.Constants" +Common = require "l0.DependencyControl.Common" +Enum = require "l0.DependencyControl.Enum" + +msgs = { + addExtraFeed: { + feedAdded: "Added '%s' to your extra feeds." + } + block: { + feedAdded: "Added '%s' to your blocked feeds." + } + fetchUntrustedFeeds: { + invalidPolicy: "Invalid fetchUntrustedFeeds policy '%s'; must be one of 'always', 'never' or 'prompt'." + } + removeExtraFeed: { + removed: "Removed '%s' from your extra feeds." + } + trust: { + feedAdded: "Added '%s' to your trusted feeds." + } + unblock: { + feedRemoved: "Removed '%s' from your blocked feeds." + } + untrust: { + feedRemoved: "Removed '%s' from your trusted feeds." + } +} + +---A normalized feed block entry: the URL/prefix to match, how to match it, and why. +---@class BlockedFeedEntry +---@field url string The feed URL or URL prefix to match. +---@field matchMode BlockedFeedMatchMode How `url` is matched against a candidate feed URL. +---@field reason? string Human-readable explanation of why the feed is blocked. +---@field isOfficial? boolean True for an entry from the official block list (read-only); false for a user block. + +---Owns DependencyControl's feed-trust model: the official trust lists (loaded from DepCtrl's own feed), +---the merged trusted/blocked sets (official plus the user's config), the trust queries the resolver asks +---per candidate, and the user-config mutations. Built and exposed by `Updater` as `updater.feedTrust`. +---@class FeedTrust +class FeedTrust + ---@alias BlockedFeedMatchMode + ---| "prefix" # Prefix: matches any feed URL starting with this value (case-insensitive) + ---| "exact" # Exact: matches only this exact feed URL (case-insensitive) + @BlockMatchMode = Enum "BlockedFeedMatchMode", { + Prefix: "prefix" + Exact: "exact" + } + + ---@alias FeedTrustStatus + ---| "trusted-official" # TrustedOfficial: trusted only through DependencyControl's official set + ---| "trusted-user" # TrustedUser: trusted only through one of the user's own lists (extraFeeds or trustedFeeds) + ---| "trusted-both" # TrustedBoth: trusted through both the official set and one of the user's own lists + ---| "untrusted" # Untrusted: neither trusted nor blocked (the default) + ---| "blocked" # Blocked: matched by the block list, which overrides trust + @TrustStatus = Enum "FeedTrustStatus", { + TrustedOfficial: "trusted-official" + TrustedUser: "trusted-user" + TrustedBoth: "trusted-both" + Untrusted: "untrusted" + Blocked: "blocked" + } + + ---@alias FeedFetchDecision + ---| "allow" # Allow: fetch without asking (trusted, or untrusted under fetchUntrustedFeeds = always) + ---| "deny" # Deny: never fetch (blocked, or untrusted under fetchUntrustedFeeds = never) + ---| "prompt" # Prompt: ask the user before fetching (untrusted under fetchUntrustedFeeds = prompt) + @FetchDecision = Enum "FeedFetchDecision", { + Allow: "allow" + Deny: "deny" + Prompt: "prompt" + } + + -- Default fetch policy for untrusted feeds, applied when the config key is unset. + ---@type FetchUntrustedFeeds + @defaultFetchUntrustedFeeds = Common.FetchUntrustedFeeds.Always + + ---@param config ConfigView The updater's config view; its `c` holds `extraFeeds`/`trustedFeeds`/`blockedFeeds`. + ---@param logger? Logger Logger for the trust/block confirmations. + ---@param feedLoader FeedLoader Loads DependencyControl's own feed for the official trust lists. + new: (@config, @logger, @feedLoader) => + + ---Lazily loads and caches DependencyControl's official trust lists from its own feed. Best-effort: if the + ---feed can't be loaded, only DepCtrl's own feed URL is trusted and nothing is blocked. + ---@private + ---@return { trusted: table<string,boolean>, blocked: table[] } official Cached official trusted-feed set and raw block-list entries. + __loadOfficial: => + return @__official if @__official + trusted, blocked = {[constants.DEPCTRL_FEED_URL]: true}, {} + feed = @feedLoader\load constants.DEPCTRL_FEED_URL, {autoLoad: false} + unless feed\ensureLoaded! + -- when the load fails (e.g. offline), return the best-effort fallback without caching, so a + -- later call retries once the feed becomes reachable (e.g. after the updater fetches it into the cache) + return {:trusted, :blocked} + Common.makeSet feed\getKnownFeeds!, trusted + blocked = feed.data.blockedFeeds or {} + @__official = {:trusted, :blocked} + return @__official + + ---Returns the feed URLs DependencyControl officially trusts (its own feed URL plus the feeds it advertises). + ---@return table<string,boolean> trustedFeeds + getOfficialTrustedFeeds: => @__loadOfficial!.trusted + + ---Returns DependencyControl's official block list as raw `{url, matchMode?, reason?}` entries, exactly as + ---declared in its own feed. + ---@return BlockedFeedEntry[] blockedFeeds + getOfficialBlockedFeeds: => @__loadOfficial!.blocked + + ---Returns the user's own trusted feeds: the union of `extraFeeds` (discovery roots) and `trustedFeeds` + ---(trust-only). Cached; invalidated when the user's lists change. + ---@return table<string,boolean> userTrustedFeeds + getUserTrustedFeeds: => + unless @__userTrusted + c = @config.c.feeds + set = {} + Common.makeSet c.extraFeeds or {}, set + Common.makeSet c.trustedFeeds or {}, set + @__userTrusted = set + return @__userTrusted + + ---Returns the merged trusted feed-URL set: the official feeds plus the user's own trusted feeds + ---(`extraFeeds` and `trustedFeeds`). Cached; invalidated when the user's lists change. + ---@return table<string,boolean> trustedFeeds + getTrustedFeeds: => + unless @__trusted + merged = {url, true for url in pairs @getOfficialTrustedFeeds!} + merged[url] = true for url in pairs @getUserTrustedFeeds! + @__trusted = merged + return @__trusted + + ---Returns a merged, normalized list of the "officially" blocked feeds (as per DependencyControls own feed), + ---followed by the user's blocked feeds. Block overrides trust. + ---@return BlockedFeedEntry[] blockedFeeds The merged list of normalized block entries, each tagged `isOfficial` when it comes from the official feed. + getBlockedFeeds: => + unless @__blocked + entries = {} + for raw in *@getOfficialBlockedFeeds! + entry = @@__normalizeBlockEntry raw + if entry + entry.isOfficial = true + entries[#entries + 1] = entry + for raw in *(@config.c.feeds.blockedFeeds or {}) + entry = @@__normalizeBlockEntry raw + if entry + entry.isOfficial = false + entries[#entries + 1] = entry + @__blocked = entries + return @__blocked + + ---Reports whether a feed URL is in the merged trusted set (exact match). + ---@param url? string The feed URL to check. + ---@return boolean trusted True when the feed URL is trusted, false otherwise. + isTrusted: (url) => url and @getTrustedFeeds![url] and true or false + + ---Reports whether a feed URL is trusted through one of the user's own lists (`extraFeeds` or `trustedFeeds`), + ---as opposed to DependencyControl's official set. A block does not factor into this. + ---@param url? string The feed URL to check. + ---@return boolean userTrusted True when the feed URL is in one of the user's trust lists. + isUserTrusted: (url) => url and @getUserTrustedFeeds![url] and true or false + + ---Reports whether a feed URL is in DependencyControl's official trusted set (its own feed plus the feeds it + ---advertises), as opposed to the user's own lists. A block does not factor into this. + ---@param url? string The feed URL to check. + ---@return boolean officiallyTrusted True when the feed URL is officially trusted. + isOfficiallyTrusted: (url) => url and @getOfficialTrustedFeeds![url] and true or false + + ---Reports whether a feed URL is matched by any block entry (official or user). + ---@param url? string The feed URL to check. + ---@return boolean blocked True when the feed URL is blocked, false otherwise. + isBlocked: (url) => @getBlockingEntry(url) != nil + + ---Returns the block entry that matches a feed URL or nil if none does. + ---Useful to get the reason for a block. + ---@param url? string The feed URL to check. + ---@return BlockedFeedEntry? entry The first matching block entry, or nil. + getBlockingEntry: (url) => + return nil unless url + for entry in *@getBlockedFeeds! + return entry if @@matchesBlockEntry url, entry + return nil + + ---Classifies a feed URL's trust: a block overrides any trust, and official vs user trust are reported + ---independently (a feed in both is `TrustedBoth`). + ---@param url? string The feed URL to classify. + ---@return FeedTrustStatus status + ---@return BlockedFeedEntry? blockingEntry The block entry that matched when the feed is blocked, else nil. + getTrustStatus: (url) => + blockingEntry = @getBlockingEntry url + return @@TrustStatus.Blocked, blockingEntry if blockingEntry + official = @isOfficiallyTrusted url + user = @isUserTrusted url + return @@TrustStatus.TrustedBoth if official and user + return @@TrustStatus.TrustedUser if user + return @@TrustStatus.TrustedOfficial if official + @@TrustStatus.Untrusted + + ---Classifies whether a feed may be fetched, from its block/trust status and the `fetchUntrustedFeeds` + ---policy: a blocked feed is always denied, a trusted feed always allowed, and an untrusted feed follows + ---the policy (always → allow, never → deny, prompt → prompt); an unrecognized policy fails closed (deny). + ---It neither prompts nor mutates trust state. + ---@param url? string The feed URL to classify. + ---@return FeedFetchDecision decision + getFetchDecision: (url) => + return @@FetchDecision.Deny if @isBlocked url + return @@FetchDecision.Allow if @isTrusted url + switch @config.c.feeds.fetchUntrustedFeeds or @@defaultFetchUntrustedFeeds + when Common.FetchUntrustedFeeds.Never then @@FetchDecision.Deny + when Common.FetchUntrustedFeeds.Prompt then @@FetchDecision.Prompt + when Common.FetchUntrustedFeeds.Always then @@FetchDecision.Allow + else + @logger\warn msgs.fetchUntrustedFeeds.invalidPolicy, @config.c.feeds.fetchUntrustedFeeds + @@FetchDecision.Deny + + ---Resolves whether a feed may be fetched now, asking through the prompter (see setPrompter) when the + ---policy is `prompt`. A prompt answer is remembered for the session so the same feed isn't asked twice; + ---with no prompter available (e.g. headless), a `prompt` policy denies — the safe default. + ---@param url? string The feed URL to check. + ---@return boolean fetch True when the feed may be fetched. + shouldFetch: (url) => + switch @getFetchDecision url + when @@FetchDecision.Allow then true + when @@FetchDecision.Deny then false + else @__resolvePrompt url + + ---Sets the callback consulted for an untrusted feed under the `prompt` policy. It receives the feed URL + ---and this FeedTrust (so it may trust/block the feed) and returns whether to fetch it now. + ---@param prompter? fun(url: string, feedTrust: FeedTrust): boolean The prompter, or nil to remove it. + setPrompter: (@prompter) => + + ---@private + ---@param url string + ---@return boolean fetch + __resolvePrompt: (url) => + @__promptAnswers or= {} + answer = @__promptAnswers[url] + return answer unless answer == nil + prompter = @prompter + answer = prompter and prompter(url, @) and true or false + @__promptAnswers[url] = answer + return answer + + ---Appends a feed URL to one of the user's config lists (skipping an exact duplicate), invalidates the + ---cached trusted set, and persists. + ---@private + ---@param configKey string The user config array field ("trustedFeeds" or "extraFeeds"). + ---@param feedUrl string The exact feed URL to add. + ---@return boolean added True when the URL was added, false when it was already present. + __addUserFeed: (configKey, feedUrl) => + list = [url for url in *(@config.c.feeds[configKey] or {})] + return false if Common.listIncludes list, feedUrl + list[#list + 1] = feedUrl + @config.c.feeds[configKey] = list + @__trusted, @__userTrusted = nil, nil + @config\save! + return true + + ---Removes every exact match of a feed URL from one of the user's config lists, invalidates the cached + ---trusted set, and persists when something changed. + ---@private + ---@param configKey string The user config array field ("trustedFeeds" or "extraFeeds"). + ---@param feedUrl string The exact feed URL to remove. + ---@return boolean removed True when a feed was removed from the user's config, false when no entry matched. + __removeUserFeed: (configKey, feedUrl) => + list = @config.c.feeds[configKey] or {} + kept = [url for url in *list when url != feedUrl] + return false if #kept == #list + @config.c.feeds[configKey] = kept + @__trusted, @__userTrusted = nil, nil + @config\save! + return true + + ---Adds a feed URL to the user's `trustedFeeds` list in the DependencyControl config file (ignoring an exact duplicate). + ---@param feedUrl string The exact (case-sensitive) feed URL to trust. + ---@return boolean added True when the feed was added to the user's `trustedFeeds`, false when it was already present. + trust: (feedUrl) => + added = @__addUserFeed "trustedFeeds", feedUrl + @logger\log msgs.trust.feedAdded, feedUrl if added and @logger + return added + + ---Removes a feed URL from the user's `trustedFeeds` list in the DependencyControl config file. + ---Feeds trusted through the official list or `extraFeeds` are unaffected; block the feed to override those. + ---@param feedUrl string The exact (case-sensitive) feed URL to untrust. + ---@return boolean removed True, when the feed was removed from the user's `trustedFeeds`, false when it wasn't present. + untrust: (feedUrl) => + removed = @__removeUserFeed "trustedFeeds", feedUrl + @logger\log msgs.untrust.feedRemoved, feedUrl if removed and @logger + return removed + + ---Adds a new entry to the user's `blockedFeeds` in the DependencyControl config file, + ---unless a block with the same url and match mode is already present. + ---@param feedUrl string The feed URL or URL prefix to block. + ---@param opts? { matchMode?: BlockedFeedMatchMode, reason?: string } The match mode (default prefix) and an optional reason. + ---@return boolean added False when an equivalent block (same url and match mode) was already present. + block: (feedUrl, opts = {}) => + -- validate the mode at the mutation boundary (an unknown mode defaults to prefix, matching the + -- read-time normalization) so a bogus value isn't persisted + matchMode = @@BlockMatchMode\validate(opts.matchMode) and opts.matchMode or @@BlockMatchMode.Prefix + entries = [e for e in *(@config.c.feeds.blockedFeeds or {})] + for raw in *entries + norm = @@__normalizeBlockEntry raw + -- dedup case-insensitively, since matchesBlockEntry itself matches case-insensitively + return false if norm and norm.url\lower! == feedUrl\lower! and norm.matchMode == matchMode + entries[#entries + 1] = {url: feedUrl, :matchMode, reason: opts.reason} + @config.c.feeds.blockedFeeds = entries + @__blocked = nil + @config\save! + @logger\log msgs.block.feedAdded, feedUrl if @logger + return true + + ---Removes every entry from the user's `blockedFeeds` list in the DependencyControl config file, + ---whose url matches `feedUrl`. Does not affect the official block list. + ---@param feedUrl string The blocked url/prefix to remove. + ---@return boolean removed True when a user block was removed, false when no user block matched. + unblock: (feedUrl) => + list = @config.c.feeds.blockedFeeds or {} + kept = [raw for raw in *list when (@@__normalizeBlockEntry(raw) or {}).url != feedUrl] + return false if #kept == #list + @config.c.feeds.blockedFeeds = kept + @__blocked = nil + @config\save! + @logger\log msgs.unblock.feedRemoved, feedUrl if @logger + return true + + ---Adds a feed URL to the user's `extraFeeds` config (ignoring an exact duplicate) and persists it. Extra + ---feeds are trusted and act as discovery roots. + ---@param feedUrl string The exact (case-sensitive) feed URL to add. + ---@return boolean added False when the feed was already in the user's `extraFeeds`. + addExtraFeed: (feedUrl) => + added = @__addUserFeed "extraFeeds", feedUrl + @logger\log msgs.addExtraFeed.feedAdded, feedUrl if added and @logger + return added + + ---Removes a feed URL from the user's `extraFeeds` config and persists it. + ---@param feedUrl string The exact (case-sensitive) feed URL to remove. + ---@return boolean removed False when the feed was not in the user's `extraFeeds`. + removeExtraFeed: (feedUrl) => + removed = @__removeUserFeed "extraFeeds", feedUrl + @logger\log msgs.removeExtraFeed.feedRemoved, feedUrl if removed and @logger + return removed + + ---Reports whether a URL is matched by any of the given prefixes. + ---Case-insensitive to resist evasion attempts. + ---@param url? string The feed URL to check. + ---@param prefixes? string[] The list of prefixes to match against. + ---@return boolean matches True when the feed URL matches any of the prefixes, false otherwise. + @urlMatchesPrefix = (url, prefixes = {}) => + return false unless url + url = url\lower! + for prefix in *prefixes + prefix = prefix\lower! + return true if #prefix > 0 and url\sub(1, #prefix) == prefix + return false + + ---Normalizes a raw block table into a `BlockedFeedEntry`, applying defaults as per the schema. + ---@private + ---@param entry { url: string, matchMode?: string, reason?: string } A raw entry from a feed's or the user's `blockedFeeds`. + ---@return BlockedFeedEntry? normalized The normalized entry, or nil when it carries no url. + @__normalizeBlockEntry = (entry) => + return nil unless type(entry) == "table" and type(entry.url) == "string" + matchMode = @BlockMatchMode\validate(entry.matchMode) and entry.matchMode or @BlockMatchMode.Prefix + return {url: entry.url, :matchMode, reason: entry.reason} + + ---Reports whether a URL is matched by a single block entry, per its match mode. + ---All matches are case-insensitive to combat evasion attempts. + ---@param url? string The feed URL to check. + ---@param entry BlockedFeedEntry A normalized block entry. + ---@return boolean matches True when the feed URL is blocked, false otherwise. + @matchesBlockEntry = (url, entry) => + return false unless url and entry and entry.url + return url\lower! == entry.url\lower! if entry.matchMode == @BlockMatchMode.Exact + return @urlMatchesPrefix url, {entry.url} + +return FeedTrust diff --git a/modules/l0/DependencyControl/FileCache.moon b/modules/l0/DependencyControl/FileCache.moon new file mode 100644 index 0000000..cf0f98d --- /dev/null +++ b/modules/l0/DependencyControl/FileCache.moon @@ -0,0 +1,258 @@ +Crypto = require "l0.DependencyControl.Crypto" +FileOps = require "l0.DependencyControl.FileOps" +Logger = require "l0.DependencyControl.Logger" +Lock = require "l0.DependencyControl.Lock" +dkjson = require "l0.dkjson" + +defaultLogger = Logger fileBaseName: "DepCtrl.FileCache" + +LOCK_NAMESPACE = "l0.DependencyControl.FileCache" -- Global-lock namespace for serializing cache writes +LOCK_TIMEOUT = 5000 -- ms to wait for the write lock before skipping the write + +-- Replaces filesystem-hostile characters so a cache entry's label is safe in a file name, and clamps its +-- length. Falls back to "entry" for an empty or missing label. +sanitizeLabel = (label) -> + safe = tostring(label or "entry")\gsub "[^%w%._-]", "_" + #safe > 0 and safe\sub(1, 64) or "entry" + +-- The 7-hex-char SHA-1 slug of a cache key. Deterministic per key (sensitive to its exact bytes), matching +-- the DepCtrl Browser's feed-URL slug convention. +keySlug = (key) -> Crypto.sha1(key)\sub 1, 7 + +-- The embedded UTC timestamp of a snapshot file name, for chronological ordering ("" when absent). +snapshotStamp = (fileName) -> fileName\match "(%d+T%d+Z)" or "" + +-- An instance's on-disk directory: the configured base, namespaced and named. The single place the layout +-- is defined, shared by the constructor and the `get` factory's registry key. +resolveDir = (basePath, namespace, name) -> "#{aegisub.decode_path basePath}/#{namespace}/#{name}" + +---The per-key index entry FileCache persists next to each snapshot; returned by getMeta/getFile/get/put. +---@class FileCacheMeta +---@field key string The cache key this entry indexes. +---@field cachedAt integer Unix time the latest snapshot was written. +---@field expiresAt integer Unix time the entry turns stale, fixed at write time. +---@field latestFile string File name of the latest snapshot, relative to the cache directory. + +---Construction options for FileCache. +---@class FileCacheOptions +---@field maxAge? integer Default entry lifetime in seconds, used when a put doesn't set its own (default 3600). +---@field maxFiles? integer Snapshot files retained per cache before the oldest are trimmed (default 50). +---@field logger? Logger Logger for cache operations. +---@field now? fun(): integer Clock override returning Unix time; defaults to os.time (injected in tests). +---@field deserialize? fun(content: string): any Codec turning stored content into the value get returns and memoizes; its presence enables the in-memory L1 layer. + +---A persistent, key-addressed cache of JSON blobs on disk. Each `put` is stored as a human-readable, +---timestamped snapshot file (which doubles as a history), indexed by a deterministic per-key meta file that +---names the latest snapshot and records when it was cached. An instance lives under +---`<basePath>/<namespace>/<name>`, so distinct caches (feeds, and others later) share one configurable cache +---base without colliding. Prefer the `get` factory over the constructor so instances are shared per directory. +---With a `deserialize` codec, `get` adds an in-memory L1 layer over the on-disk L2: it returns the +---parsed snapshot and memoizes it, keyed to the snapshot's cache time so a newer `put` (here or in another +---process) transparently supersedes the memo. +---@class FileCache +class FileCache + @defaultMaxAge = 3600 -- default entry lifetime (seconds), fixed into each entry's index at write time + @defaultMaxFiles = 50 -- how many snapshot files to retain before trimming the oldest + + -- Shared instances keyed by resolved directory, so callers for the same cache get one instance (and thus + -- share its L1 memo). Weak values let an unreferenced cache be collected while a live one stays shared. + @__instances = setmetatable {}, __mode: "v" + + ---Returns the shared cache for a base/namespace/name, reusing the existing instance for that resolved + ---directory rather than constructing a duplicate. Options apply only when the instance is first created. + ---@param basePath string The cache root (the `paths.cache` setting, e.g. "?user/cache"); path-decoded. + ---@param namespace string The owning script namespace (`constants.DEPCTRL_NAMESPACE` for DepCtrl's own caches). + ---@param name string A short name for this cache's purpose (e.g. "feeds"). + ---@param opts? FileCacheOptions See new. + ---@return FileCache + @get = (basePath, namespace, name, opts) -> + dir = resolveDir basePath, namespace, name + cache = FileCache.__instances[dir] + unless cache + cache = FileCache basePath, namespace, name, opts + FileCache.__instances[dir] = cache + return cache + + ---@param basePath string The cache root (the `paths.cache` setting, e.g. "?user/cache"); path-decoded here. + ---@param namespace string The owning script namespace (`constants.DEPCTRL_NAMESPACE` for DepCtrl's own caches). + ---@param name string A short subdirectory naming this cache's purpose (e.g. "feeds"). + ---@param opts? FileCacheOptions Defaults for entry lifetime, retention, logging, clock, and the L1 codec. + new: (basePath, namespace, name, opts = {}) => + @cacheDir = resolveDir basePath, namespace, name + @maxAge = opts.maxAge or @@defaultMaxAge + @maxFiles = opts.maxFiles or @@defaultMaxFiles + @logger = opts.logger or defaultLogger + @now = opts.now or os.time + @__deserialize = opts.deserialize + -- L1: key -> {value, cachedAt}; each memo mirrors one L2 snapshot and is superseded when its cachedAt does + @__l1 = {} + + ---@private + ---@param key string The cache key. + ---@return string path Filesystem path of the key's cache-index (meta) JSON file. + __metaPath: (key) => FileOps.joinPath @cacheDir, "#{keySlug key}.meta.json" + + ---Reads and decodes a cache index (meta) JSON file. + ---@private + ---@param path string + ---@return FileCacheMeta? meta + __readMeta: (path) => + content = FileOps.readFile path + return nil unless content + -- a torn read from a concurrent write fails to decode and is treated as a cache miss + ok, meta = pcall dkjson.decode, content + ok and type(meta) == "table" and meta or nil + + ---Reads the cache index entry for a key. + ---@param key string + ---@return FileCacheMeta? meta nil when the key isn't cached. + getMeta: (key) => @__readMeta @__metaPath key + + ---Whether a cache entry is still fresh, i.e. its expiry (fixed when it was written) hasn't passed + ---and it hasn't been invalidated by expireAll. The expiry is intrinsic to the entry, so freshness is the + ---same regardless of which instance asks or what its current `maxAge` is. + ---@param meta FileCacheMeta? A meta returned by getMeta. + ---@return boolean fresh + isFresh: (meta) => + return false unless meta and meta.expiresAt + return false if @__staleBefore and meta.cachedAt and meta.cachedAt < @__staleBefore + @.now! < meta.expiresAt + + ---Resolves the latest cached snapshot for a key. The snapshot may be stale; callers use isFresh on the + ---returned meta to decide whether to serve it directly or only as an offline fallback. + ---@param key string + ---@return string? path The snapshot file's path, or nil when the key isn't cached (or its file is gone). + ---@return FileCacheMeta? meta The cache index entry (returned even when the snapshot file is missing). + getFile: (key) => + meta = @getMeta key + return nil unless meta and meta.latestFile + path = FileOps.joinPath @cacheDir, meta.latestFile + return nil, meta unless "file" == FileOps.attributes path, "mode" + return path, meta + + ---Returns the deserialized latest snapshot for a key, served from the in-memory L1 memo when it still + ---mirrors the on-disk snapshot, otherwise read and deserialized from L2 and memoized. The value may be + ---stale — freshness is reported separately so the caller can serve it or treat it as an offline fallback. + ---Requires a `deserialize` codec; without one this always misses. + ---@param key string + ---@return any? value The deserialized snapshot (the freshest available, even when stale), or nil if not cached. + ---@return FileCacheMeta? meta The snapshot's index entry, returned even when stale. + ---@return boolean fresh Whether the returned snapshot is still within its expiry. + get: (key) => + meta = @getMeta key + return nil unless meta and meta.cachedAt + fresh = @isFresh meta + memo = @__l1[key] + return memo.value, meta, fresh if memo and memo.cachedAt == meta.cachedAt + return nil, meta, fresh unless @__deserialize and meta.latestFile + + path = FileOps.joinPath @cacheDir, meta.latestFile + content = "file" == FileOps.attributes(path, "mode") and FileOps.readFile path + return nil, meta, fresh unless content + + value = @.__deserialize content + @__l1[key] = {:value, cachedAt: meta.cachedAt} + return value, meta, fresh + + ---Marks every entry cached before the cut-off as stale, so the next get/isFresh reports it not fresh. The + ---snapshot is kept as an offline fallback, and a later put makes the entry fresh again. When purging, the + ---affected entries are instead dropped from the L1 memo and their on-disk snapshots and index deleted, to + ---reclaim space or hard-reset. + ---@param before? integer Cut-off Unix time; entries cached strictly before it are affected (default: now). + ---@param purge? boolean Delete the affected entries (L1 + L2) instead of only marking them stale (default false). + expireAll: (before = @.now!, purge = false) => + @__staleBefore = before + return unless purge + + files = FileOps.listDir @cacheDir + return unless files + + -- collect the key slugs whose index predates the cut-off, dropping their memos as we go + expiredSlugs = {} + for file in *files + continue unless file\match "%.meta%.json$" + meta = @__readMeta FileOps.joinPath @cacheDir, file + continue unless meta and meta.cachedAt and meta.cachedAt < before + expiredSlugs[keySlug meta.key] = true + @__l1[meta.key] = nil + + -- every file (snapshot or index) is named with its key's 7-hex slug prefix, so one pass removes both + for file in *files + slug = file\match "^(%x%x%x%x%x%x%x)" + FileOps.remove FileOps.joinPath @cacheDir, file if slug and expiredSlugs[slug] + + ---Stores a blob under a readable, timestamped snapshot and repoints the index at it, then trims old + ---snapshots. The snapshot name is `<slug>-<label>-<utcTimestamp>-<rand>.json` (slug first so a key's + ---snapshots sort together). + ---@param key string The cache key. + ---@param content string The blob to persist verbatim. + ---@param label? string A human-readable name, used to make the snapshot file recognizable. + ---@param expiresAfter? integer Seconds until this entry expires, fixed into the index now (default: this cache's `maxAge`). Pass a per-resource lifetime to honor, e.g., an HTTP `max-age`. + ---@return FileCacheMeta? meta The written index entry, or nil on a filesystem error or a lock timeout. + ---@return string? err + put: (key, content, label, expiresAfter = @maxAge) => + -- The meta index is a single mutable file; concurrent DepCtrl instances/processes could interleave + -- writes and corrupt it, so serialize under a Global lock. A failed acquire returns (nil, err) and + -- the caller just leaves the data uncached this round. + meta, err = Lock\guard { + namespace: LOCK_NAMESPACE + resource: @cacheDir + scope: Lock.Scope.Global + holderName: "FileCache" + logger: @logger + timeout: LOCK_TIMEOUT + }, -> @__write key, content, label, expiresAfter + -- refresh the L1 memo to the snapshot just written, so get() serves it without re-reading L2 + -- (@.__deserialize, not @__deserialize, so the stored codec is called plainly, without self) + @__l1[key] = {value: @.__deserialize(content), cachedAt: meta.cachedAt} if meta and @__deserialize + return meta, err + + ---Writes a snapshot, points the index at it, and trims. Runs under the cache write lock held by put. + ---@private + ---@param key string The cache key. + ---@param content string The blob to persist verbatim. + ---@param label? string A human-readable name, used to make the snapshot file recognizable. + ---@param expiresAfter integer Seconds until expiry, from the write time; stamped into the index as an absolute `expiresAt`. + ---@return FileCacheMeta? meta The written index entry, or nil on a filesystem error. + ---@return string? err Error message when a write failed, nil on success. + __write: (key, content, label, expiresAfter) => + dirRes, dirErr = FileOps.mkdir @cacheDir, false, true + return nil, dirErr if dirRes == nil + + now = @.now! + stamp = os.date "!%Y%m%dT%H%M%SZ", now + rand = "%04X"\format math.random 0, 16^4 - 1 + fileName = "#{keySlug key}-#{sanitizeLabel label}-#{stamp}-#{rand}.json" + + ok, err = FileOps.writeFile {@cacheDir, fileName}, content, true + return nil, err unless ok + + meta = {:key, cachedAt: now, expiresAt: now + expiresAfter, latestFile: fileName} + ok, err = FileOps.writeFile @__metaPath(key), (dkjson.encode meta, {indent: true, indentMode: "prettier"}), true + return nil, err unless ok + + @__trim! + return meta + + ---Caps the number of retained snapshot files at `maxFiles`, deleting the oldest first but never one an + ---index still points at (an entry's current snapshot survives regardless of the cap). + ---@private + __trim: => + files = FileOps.listDir @cacheDir + return unless files + + protectedFiles, snapshots = {}, {} + for file in *files + if file\match "%.meta%.json$" + meta = @__readMeta FileOps.joinPath @cacheDir, file + protectedFiles[meta.latestFile] = true if meta and meta.latestFile + elseif file\match "%.json$" + snapshots[#snapshots + 1] = file + + -- newest first, so everything past the cap is the oldest + table.sort snapshots, (a, b) -> snapshotStamp(a) > snapshotStamp(b) + for i = @maxFiles + 1, #snapshots + continue if protectedFiles[snapshots[i]] + FileOps.remove FileOps.joinPath @cacheDir, snapshots[i] + +return FileCache diff --git a/modules/l0/DependencyControl/FileLock.moon b/modules/l0/DependencyControl/FileLock.moon new file mode 100644 index 0000000..9b6e440 --- /dev/null +++ b/modules/l0/DependencyControl/FileLock.moon @@ -0,0 +1,118 @@ +ffi = require "ffi" +FileOps = require "l0.DependencyControl.FileOps" +Finalizer = require "l0.DependencyControl.Finalizer" + +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 + Finalizer.guard @, -> closeImpl handleRef + + ---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..a7a10cb --- /dev/null +++ b/modules/l0/DependencyControl/FileOps.moon @@ -0,0 +1,748 @@ +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 because this process + -- can't use long paths. Drives the descriptive error below, and is 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 (default false, so a non-empty directory is not removed). + ---@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 = false, 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\save! 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\save! + 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 + + ---Lists the names of a directory's entries, excluding `.` and `..`. + ---@param dirPath string|string[] Path or path segments of the directory. + ---@return string[]? entries The entry names, or nil when the path isn't a directory. + ---@return string? err + 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 != ".."] + + ---Recursively collects all files below a directory. + ---@param dirPath string|string[] Path or path segments of the directory to walk. + ---@return string[]? files Full paths of every file below the directory (joined onto the given path), or nil when it can't be listed. + ---@return string? err + listFilesRecursive: (dirPath) -> + entries, err = FileOps.listDir dirPath + return nil, err unless entries + files = {} + for entry in *entries + fullPath = FileOps.joinPath dirPath, entry + mode = FileOps.attributes fullPath, "mode" + if mode == "directory" + for file in *(FileOps.listFilesRecursive(fullPath) or {}) + files[#files + 1] = file + elseif mode == "file" + files[#files + 1] = fullPath + return files + + ---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 {}, true -- error is raised below via invalidPathSegmentType; contribute nothing here + + firstSegment, moreSegments = nil, nil + for segment in FileOps.pathSegments value + if firstSegment + moreSegments or= {firstSegment} + table.insert moreSegments, segment + else firstSegment = segment + -- an empty or separator-only segment has no components: return {} so it adds nothing, rather + -- than a nil that would leave a hole and stop the ipairs walk over the flattened segments + return {}, true unless firstSegment + 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 + + ---Removes a directory, by default together with everything it contains. + ---@param path string|string[] Path or path segments to the directory to remove. + ---@param recurse? boolean Remove the directory's contents first (default true); when false, only an already-empty directory is removed. + ---@return boolean? success True on success, or nil on error. + ---@return string? err An error message when the path is empty, doesn't exist, isn't a directory, or something couldn't be removed. + 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 + -- lfs implementations disagree on the success value (LuaFileSystem returns true, + -- Aegisub's lfs returns nothing), so only an explicit error or a directory that + -- still exists counts as failure + unless not err and (success or not lfs.attributes(path, "mode")) + return nil, msgs.rmdir.couldntRemoveDir\format(err or "unknown error") + + 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 + -- lfs implementations disagree on the success value (LuaFileSystem returns true, + -- Aegisub's lfs returns nothing), so only an explicit error or a directory that + -- is still missing counts as failure + unless not err and (res or "directory" == lfs.attributes(dir, "mode")) + return nil, msgs.mkdir.createError\format(err or "unknown error") + 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 + + + ---Extracts the root anchor of an absolute path. + ---@private + ---@param absolutePath string The absolute path to inspect. + ---@return string? root On Windows the drive prefix with its separator (e.g. "C:\"), on POSIX the leading slash plus first segment (e.g. "/usr"), or nil when the path has no such root. + __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/Finalizer.moon b/modules/l0/DependencyControl/Finalizer.moon new file mode 100644 index 0000000..af02114 --- /dev/null +++ b/modules/l0/DependencyControl/Finalizer.moon @@ -0,0 +1,28 @@ +constants = require "l0.DependencyControl.Constants" + +-- namespaced key under which `guard` pins its finalizer; collision-free across the shared DepCtrl namespace +FINALIZER_KEY = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Finalizer" + +---Runs a cleanup action when a value is garbage-collected — a last-resort release for OS handles, locks, and +---the like when a caller forgets to release explicitly. +---@class Finalizer +class Finalizer + ---Creates a finalizer that runs the given cleanup once, when it is collected. Store it on the object whose + ---lifetime should trigger the cleanup. The cleanup must capture the resources it frees, not the owning + ---object — capturing the owner keeps it reachable, so the finalizer never runs. + ---@param cleanup fun() Runs once when the finalizer is collected; its errors are swallowed. + ---@return userdata finalizer An opaque value to anchor; it exposes no usable fields. + @create = (cleanup) -> + finalizer = newproxy true + (getmetatable finalizer).__gc = -> pcall cleanup + finalizer + + ---Creates a finalizer and pins it to the anchor under a private key, so the cleanup runs when the anchor is + ---collected. Pass an instance to release its resources on GC, or a class to tie the cleanup to the module's + ---lifetime. The cleanup must capture the resources it frees, not the anchor. + ---@param anchor table The instance or class whose collection triggers the cleanup. + ---@param cleanup fun() Runs once when the anchor is collected; its errors are swallowed. + ---@return table anchor The same anchor, for chaining. + @guard = (anchor, cleanup) -> + anchor[FINALIZER_KEY] = Finalizer.create cleanup + anchor diff --git a/modules/l0/DependencyControl/GitRepository.moon b/modules/l0/DependencyControl/GitRepository.moon new file mode 100644 index 0000000..bf0da94 --- /dev/null +++ b/modules/l0/DependencyControl/GitRepository.moon @@ -0,0 +1,38 @@ +---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 <dir>`. + ---@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 + + + ---Returns the branch name the given ref resolves to. + ---@param ref? string Git ref to resolve (defaults to HEAD). + ---@return string? branch Short branch name, or nil when the command fails. + getBranch: (ref = "HEAD") => @run "rev-parse --abbrev-ref #{ref}" + ---Returns the abbreviated commit hash of the given ref. + ---@param ref? string Git ref to resolve (defaults to HEAD). + ---@return string? hash Seven-character commit hash, or nil when the command fails. + getCommitHash: (ref = "HEAD") => @run "rev-parse --short=7 #{ref}" + ---Reports whether the given ref sits exactly on a tag. + ---@param ref? string Git ref to test (defaults to HEAD). + ---@return boolean atTag True when the ref points exactly at a tag. + 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, "-<branch>-g<hash>" 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/Host.moon b/modules/l0/DependencyControl/Host.moon new file mode 100644 index 0000000..0910c9c --- /dev/null +++ b/modules/l0/DependencyControl/Host.moon @@ -0,0 +1,159 @@ +resolveHost = require "l0.DependencyControl.helpers.resolve-host" +Accessors = require "l0.DependencyControl.Accessors" + +-- Parses one inet_aton numeric component: 0x-prefixed hex, 0-prefixed octal, or decimal. nil if invalid. +parseAtonPart = (s) -> + if s\match "^0[xX]%x+$" + tonumber s\sub(3), 16 + elseif s\match "^0[0-7]+$" + tonumber s, 8 + elseif s\match "^%d+$" + tonumber s, 10 + else + nil + +-- Whether a 4-byte IPv4 address falls in a non-public range (loopback, private, link-local, CGNAT, ...). +isPrivateIPv4 = (b) -> + b0, b1 = b[1], b[2] + return true if b0 == 0 -- 0.0.0.0/8 (this host) + return true if b0 == 10 -- 10.0.0.0/8 (private) + return true if b0 == 127 -- 127.0.0.0/8 (loopback) + return true if b0 == 169 and b1 == 254 -- 169.254.0.0/16 (link-local, incl. cloud metadata) + return true if b0 == 172 and b1 >= 16 and b1 <= 31 -- 172.16.0.0/12 (private) + return true if b0 == 192 and b1 == 168 -- 192.168.0.0/16 (private) + return true if b0 == 100 and b1 >= 64 and b1 <= 127 -- 100.64.0.0/10 (carrier-grade NAT) + return true if b0 >= 240 -- 240.0.0.0/4 (reserved, incl. 255.255.255.255) + return false + +-- Whether a 16-byte IPv6 address falls in a non-public range; IPv4-mapped addresses defer to isPrivateIPv4. +isPrivateIPv6 = (b) -> + -- ::ffff:a.b.c.d — classify the embedded IPv4 + mapped = b[11] == 0xff and b[12] == 0xff + if mapped + for i = 1, 10 + if b[i] != 0 + mapped = false + break + return isPrivateIPv4 {b[13], b[14], b[15], b[16]} if mapped + + -- :: (unspecified) and ::1 (loopback) + highAllZero = true + for i = 1, 15 + if b[i] != 0 + highAllZero = false + break + return true if highAllZero and (b[16] == 0 or b[16] == 1) + + b0, b1 = b[1], b[2] + return true if b0 == 0xfe and b1 >= 0x80 and b1 <= 0xbf -- fe80::/10 (link-local) + return true if b0 == 0xfc or b0 == 0xfd -- fc00::/7 (unique local) + return false + +---A host — a hostname or IP literal — that reports whether it targets a non-public IP address. +---@class Host +---@field isPrivate boolean Whether this host targets a non-public address — private, loopback, link-local, or reserved. An unresolvable host reads as false, since its fetch fails on its own. Read-only, memoized on first read. +class Host + -- Default resolver for non-literal hosts, mapping a hostname to a list of address byte arrays. Each + -- instance copies it at construction, and tests override it via the constructor. + ---@private + @__resolver = resolveHost + + ---@param host string A hostname or IP-literal string. + ---@param resolver? fun(host: string): integer[][]? Address resolver (default: the class-level resolver). + new: (@host, @__resolver = @@__resolver) => + @__literal = Host.parseIPv4Literal @host + + ---Resolves this host to a list of address byte arrays, cached across calls. A literal host yields its own + ---bytes without resolving; a non-literal host is looked up via the resolver (nil when it can't be resolved). + ---@return integer[][]? addresses + addresses: => + return {@__literal} if @__literal + if @__resolved == nil + resolve = @__resolver + @__resolved = resolve and resolve(@host) or false + @__resolved or nil + + isPrivate: Accessors.property + get: => + if @__private == nil + if @__literal + @__private = Host.isPrivateAddress @__literal + else + @__private = false + if addresses = @addresses! + for address in *addresses + if Host.isPrivateAddress address + @__private = true + break + @__private + + ---Builds a Host from a URL's host component, or nil when the URL carries no host. + ---@param url string The URL to extract the host from. + ---@param resolver? fun(host: string): integer[][]? Address resolver (default: the class-level resolver). + ---@return Host? host + @fromUrl = (url, resolver) -> + host = Host.getUrlHostPart url + host and Host(host, resolver) or nil + + ---Reports whether a resolved IP address is in a non-public range. + ---@param bytes integer[] The address bytes (length 4 for IPv4, 16 for IPv6). + ---@return boolean private True when the address is private/loopback/link-local/reserved. + @isPrivateAddress = (bytes) -> + switch #bytes + when 4 then isPrivateIPv4 bytes + when 16 then isPrivateIPv6 bytes + else false + + ---Extracts the host from a URL, dropping protocol, auth, port and unwrapping a bracketed `[IPv6]`. + ---@param url string The URL to extract the host from. + ---@return string? host The host, or nil when none could be extracted. + @getUrlHostPart = (url) -> + return nil unless type(url) == "string" + urlWithoutProtocol = url\gsub "^%a[%w+.-]*://", "" + originWithoutProtocol = urlWithoutProtocol\match "^([^/?#]*)" + return nil unless originWithoutProtocol + -- drop userinfo, matching the fetcher: curl/WinINet split the authority at the LAST '@', so strip + -- greedily. A first-'@' strip would leave `http://a@b@127.0.0.1/` as host `b@127.0.0.1` (unresolvable + -- → treated as public) while the fetcher connects to 127.0.0.1, defeating the private-host block. + host = originWithoutProtocol\gsub "^.*@", "" + return host\match "^%[([^%]]*)%]" if host\sub(1, 1) == "[" -- bracketed IPv6 + hostWithoutPort = host\match "^([^:]*)" + hostWithoutPort != "" and hostWithoutPort or nil + + ---Parses an IPv4 literal in inet_aton form (dotted/decimal/octal/hex, 1-4 parts) to a 4-byte array. + ---Hand-rolled to catch the legacy encodings the fetcher (curl/WinINet) accepts but a resolver's + ---inet_pton rejects — e.g. decimal `2130706433` (== 127.0.0.1), which could be used to bypass SSRF protection. + ---IPv6 literals have a single canonical form the resolver parses identically, so they don't need this. + ---@param host string The host string to parse. + ---@return integer[]? bytes The 4 address bytes, or nil when `host` isn't an IPv4 literal. + @parseIPv4Literal = (host) -> + return nil unless type(host) == "string" and #host > 0 + -- empty components (leading/trailing/double dots) aren't a valid literal + return nil if host\match("%.%.") or host\sub(1, 1) == "." or host\sub(-1) == "." + parts = [p for p in host\gmatch "[^.]+"] + n = #parts + return nil if n < 1 or n > 4 + values = {} + for i = 1, n + v = parseAtonPart parts[i] + return nil unless v + values[i] = v + byte = (v, shift) -> math.floor(v / shift) % 0x100 + switch n + when 1 + v = values[1] + return nil if v > 0xFFFFFFFF + return {byte(v, 0x1000000), byte(v, 0x10000), byte(v, 0x100), v % 0x100} + when 2 -- a.b : b fills the low 24 bits + return nil if values[1] > 0xFF or values[2] > 0xFFFFFF + return {values[1], byte(values[2], 0x10000), byte(values[2], 0x100), values[2] % 0x100} + when 3 -- a.b.c : c fills the low 16 bits + return nil if values[1] > 0xFF or values[2] > 0xFF or values[3] > 0xFFFF + return {values[1], values[2], byte(values[3], 0x100), values[3] % 0x100} + when 4 + for v in *values + return nil if v > 0xFF + return values + +Accessors.install Host +return Host diff --git a/modules/l0/DependencyControl/JsonSchema.moon b/modules/l0/DependencyControl/JsonSchema.moon new file mode 100644 index 0000000..8cf8c85 --- /dev/null +++ b/modules/l0/DependencyControl/JsonSchema.moon @@ -0,0 +1,209 @@ +json = require "json" +Logger = require "l0.DependencyControl.Logger" +FileOps = require "l0.DependencyControl.FileOps" +SemanticVersion = require "l0.DependencyControl.SemanticVersion" + +JSON_SCHEMA_ID_KEYWORD = "$schema" +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 + +---Loads and memoizes the lua-schema library, installing the `pattern`-keyword shadow on first successful load. +---@return table|false luaSchemaLib The lua-schema library, or false when it isn't installed. +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 "<location>: <error>" strings. +---@param result table A lua-schema result node: a leaf carrying `.error` at `.instanceLocation`, or a branch nesting further results under `.errors`. +---@param acc? string[] Accumulator for the recursive descent (callers omit it). +---@return string[] errors The flattened "<location>: <error>" messages, empty when the node carries none. +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" + } + } + } + + -- The JSON Schema keyword whose value declares which schema a document conforms to (`$schema`). + @JSON_SCHEMA_ID_KEYWORD = JSON_SCHEMA_ID_KEYWORD + + ---Finds the versioned schema files in a directory, mapping each file's declared version to its path. + ---@param schemaDir string Directory to scan. + ---@param fileNamePattern? string Lua pattern matched against each filename, capturing the version (default matches `vX.Y.Z.json`). + ---@return table<string, string>? schemaPathsByVersion Version → full file path, or nil when the directory can't be read or holds no matching file. + ---@return string? err The failure message accompanying a nil result. + @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 + + ---Validates data against the best-matching schema from the provided versions. A document's own root + ---`$schema` names its version authoritatively; otherwise the given version hint is tried first, + ---then each remaining schema highest-version-first. + ---@param data table The value to validate. + ---@param schemasByVersion table<string, string|JsonSchema> Version → schema file path or ready instance. + ---@param dataSchemaVersion? string|fun(data: table): string? Version hint for data with no `$schema`: a fixed version, or a function that derives it from the data (e.g. a feed reading its own `dependencyControlFeedFormatVersion`, a field that predates `$schema`). + ---@return boolean? valid True/false on a completed validation, nil if none could run. + ---@return string? version The schema version validated against, or nil when none matched. + ---@return string? err Validation errors on failure, or a message when validation couldn't run. + @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 + + -- a document's own root `$schema` names its version authoritatively; otherwise fall back to the hint + declaredVersion = type(data) == "table" and type(data[JSON_SCHEMA_ID_KEYWORD]) == "string" and + data[JSON_SCHEMA_ID_KEYWORD]\match "v(%d+%.%d+%.%d+)%.json" + unless declaredVersion + declaredVersion = if type(dataSchemaVersion) == "function" then dataSchemaVersion(data) else dataSchemaVersion + dataSchemaVersion = declaredVersion + + 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, SemanticVersion.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 Logger for load/parse errors (defaults to a shared JsonSchema 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..0b8cbe5 --- /dev/null +++ b/modules/l0/DependencyControl/Lock.moon @@ -0,0 +1,358 @@ +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" +Common = require "l0.DependencyControl.Common" +Enum = require "l0.DependencyControl.Enum" +Crypto = require "l0.DependencyControl.Crypto" +FileOps = require "l0.DependencyControl.FileOps" +Accessors = require "l0.DependencyControl.Accessors" +Finalizer = require "l0.DependencyControl.Finalizer" +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" + +---@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 +---@field state LockState Held when this instance holds the lock, otherwise Unknown (a foreign holder's state can't be told without acquiring). Read-only. +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: %s..." + 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" + + ---@alias LockState + ---| -1 # Unknown: the state can't be determined without trying to acquire (e.g. a foreign holder) + ---| 0 # Unavailable: the lock is held elsewhere and couldn't be acquired + ---| 1 # Available: the lock is free to acquire + ---| 2 # Held: this instance holds the lock + @LockState = Enum "LockState", { + Unknown: -1 + Unavailable: 0 + Available: 1 + Held: 2 + }, @logger + LockState or= @LockState + + ---@alias LockScope + ---| "process" # Process: only Lua states within this process contend + ---| "global" # Global: every process in the session contends via an advisory file lock + @Scope = Enum "LockScope", { + Process: "process" + Global: "global" + }, @logger + Scope = @Scope + + ---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 + ---@private + @__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 = Common.uuid! + + token, holderFilePath, lockFilePath = deriveNames @scope, @namespace, @resource + @_holderFilePath = holderFilePath + @_primitive = @@__createPrimitive @scope, token, lockFilePath + + -- mutable held-state shared with the GC finalizer (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 + Finalizer.guard @, -> + return unless state.held + pcall logger.warn, logger, msgs.new.lockNotReleased, holderName, instanceId, namespace, resource + pcall -> + primitive\unlock! + FileOps.remove holderFilePath if recordHolder + state.held = false + + ---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 + ---@private + __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, whereas the monotonic clock is local but fine-grained and steady. No-op + -- when holder recording is disabled, and best-effort so failures don't affect the lock. + ---@private + __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. + ---@private + __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. + ---@private + ---@param record table? A holder record, or nil when none is available. + ---@return string description Human-readable holder description; "another instance" when the record is nil. + __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. + ---@private + ---@param record table? A holder record, or nil. + ---@return number? deadline Unix timestamp when the holder's lease lapses, or nil when the record is nil or carries no usable timestamp. + __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 + + state: Accessors.property + get: => + 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 LockState state 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 + + -- traced once per acquisition: repeating it on every poll drowns the log during a long wait + @logger\trace msgs.lock.trying, @namespace, @resource, @holderName, @instanceId, + timeout == math.huge and "none" or "#{math.floor timeout}ms" + + timePassed = 0 + staleHolderWarned = nil + while timeout == math.huge or timeout >= timePassed + state = @state + 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 + + -- the lock is held by someone else, so surface who holds it and warn — once + -- per holder — 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 and holderDesc != staleHolderWarned + @logger\warn msgs.lock.staleHolder, @namespace, @resource, holderDesc, os.time! - deadline + staleHolderWarned = holderDesc + + break if timeout == 0 + @logger\trace msgs.lock.heldByOther, @namespace, @resource, holderDesc, lockWaitInterval + Timer.sleep lockWaitInterval + timePassed += lockWaitInterval + + @logger\trace msgs.lock.timeout, @namespace, @resource, @holderName, @instanceId + return @@LockState.Unavailable, timePassed + + ---Attempts to acquire the lock without waiting. + ---@return LockState state Held if the lock was acquired, Unavailable if it's held elsewhere. + ---@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 LockState|string statusOrErr Available on success, or an error message when the lock wasn't 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 + +Accessors.install Lock diff --git a/modules/l0/DependencyControl/Logger.moon b/modules/l0/DependencyControl/Logger.moon new file mode 100644 index 0000000..9fa6927 --- /dev/null +++ b/modules/l0/DependencyControl/Logger.moon @@ -0,0 +1,309 @@ +Timer = require "l0.DependencyControl.Timer" +NamedSemaphore = require "l0.DependencyControl.NamedSemaphore" +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 + maxToFileLevel: 5 + fileBaseName: script_namespace or "UNKNOWN" + fileSubName: "" + logDir: "?user/log" + fileTemplate: "%s/%s-%04x_%s_%s.log" + fileMatchTemplate: "%d%d%d%d%-%d%d%-%d%d%-%d%d%-%d%d%-%d%d%-%x%x%x%x_@{fileBaseName}_?.*%.log$" + prefix: "" + toFile: false, toWindow: true + indent: 0 + usePrefixFile: true + usePrefixWindow: true + indentStr: "—" + maxFiles: 200, maxAge: 604800, maxSize:10*(10^6) + + seeded = false + + -- All logger instances write to the same live stream (Aegisub's log window or the CLI's stderr), + -- so we need to track the last logger that didn't end its last message with a line feed, + -- so another logger's next message can be broken onto a fresh line instead of being glued onto it. + streamAtLineStart, streamOpenedBy = true, nil + + ---Creates a logger, applying the given option overrides to the new instance. + ---@param args? table Field overrides copied onto the logger; a `usePrefix` key sets both the file and window prefix flags. + new: (args) => + if args + @[k] = v for k, v in pairs args + if args.usePrefix ~= nil + @usePrefixFile, @usePrefixWindow = args.usePrefix, args.usePrefix + + -- scripts load simultaneously in separate Lua states, so a whole-second seed would collide; + -- the monotonic clock's sub-microsecond reading diverges per state, and the pid separates + -- whole processes + unless seeded + math.randomseed Timer.getTime! * 1000000 + NamedSemaphore.pid + math.random! for i = 1, 3 + seeded = true + + @lastHadLineFeed = true + escaped = @fileBaseName\gsub("([%%%(%)%[%]%.%*%-%+%?%$%^])","%%%1") + @fileMatch = @fileMatchTemplate\gsub "@{fileBaseName}", escaped + @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 == "" + + prefixWin = @usePrefixWindow and prefix or "" + lineFeed = insertLineFeed and "\n" or "" + indentStr = indent==0 and "" or @indentStr\rep(indent) .. " " + + msg = if @lastHadLineFeed + @format msg, indent, ... + elseif 0 < select "#", ... + (tostring msg)\format ... + + show = aegisub.log and @toWindow + if @toFile and level <= @maxToFileLevel + unless @handle + lfs.mkdir aegisub.decode_path @logDir -- best-effort: create the log dir (parent ?user exists) + @handle = io.open @fileName, "a" + unless @handle + -- missing dir / permissions / disk full: disable file logging rather than crash on every + -- subsequent call, and note it once in the window (this message still reaches the window below) + @toFile = false + aegisub.log 2, "[#{@fileBaseName}] Couldn't open log file '#{@fileName}'; file logging disabled.\n" if aegisub.log + if @handle + linePre = @lastHadLineFeed and "#{indentStr}[#{levels[level+1]\upper!}] #{os.date '%H:%M:%S'} #{show and '+' or '•'} " or "" + line = table.concat({linePre, @usePrefixFile and prefix or "", msg, lineFeed}) + @handle\write(line)\flush! + + -- for some reason the stack trace gets swallowed when not doing the replace + assert level > 1,"#{indentStr}Error: #{prefixWin}#{msg\gsub ':', ': '}" + if show + -- ensure this message starts clean instead of being glued onto unrelated output + lineBreak = (not streamAtLineStart and streamOpenedBy != @) and "\n" or "" + -- a chunk continuing this logger's own open line (e.g. a progress bar fill) must + -- not repeat the indent + winIndent = (not streamAtLineStart and streamOpenedBy == @) and "" or indentStr + aegisub.log level, table.concat({lineBreak, winIndent, prefixWin, msg, lineFeed}) + streamAtLineStart = insertLineFeed + streamOpenedBy = if insertLineFeed then nil else @ + + @lastHadLineFeed = insertLineFeed + return true + + ---Whether the last message to the log stream ended with a line feed, or left the stream mid-line. + ---Lets a caller decide whether its next output continues that open line or starts fresh. + ---@return boolean + @isAtLineStart = -> streamAtLineStart + + ---Formats a message for display, substituting printf-style arguments and indenting each line after the first. + ---@param msg string|table The message, or a list of lines joined with newlines. + ---@param indent number Indentation depth applied to every line after a line break. + ---@param ... any Format arguments substituted into the message. + ---@return string msg The formatted message. + format: (msg, indent, ...) => + if type(msg) == "table" + msg = table.concat msg, "\n" + + if 0 < select "#", ... + msg = (tostring msg)\format ... + + return msg unless indent>0 + + indentRep = @indentStr\rep(indent) + indentStr = indentRep .. " " + -- indent after line breaks and connect indentation supplied in the user message + return msg\gsub("\n", "\n"..indentStr)\gsub "\n#{indentStr}(#{@indentStr})", "\n#{indentRep}%1" + + ---Logs a message at the given severity level. + ---@param level? number|string|table Severity level; when not a number, the value is taken as the message and logged at the default level. + ---@param msg? string|table Message to log; taken as the first format argument when the level slot already holds the message. + ---@param ... any Format arguments substituted into the message. + ---@return boolean written False when nothing was passed or the message was empty, otherwise true. + log: (level, msg, ...) => + return false unless level or msg + + if "number" != type level + return @logEx @defaultLevel, level, true, nil, nil, msg, ... + else return @logEx level, msg, true, nil, nil, ... + + ---Logs the given message at the fatal level (0). + ---@param ... any Message and format arguments. + ---@return boolean written False when nothing was passed or the message was empty, otherwise true. + fatal: (...) => @log 0, ... + ---Logs the given message at the error level (1). + ---@param ... any Message and format arguments. + ---@return boolean written False when nothing was passed or the message was empty, otherwise true. + error: (...) => @log 1, ... + ---Logs the given message at the warning level (2). + ---@param ... any Message and format arguments. + ---@return boolean written False when nothing was passed or the message was empty, otherwise true. + warn: (...) => @log 2, ... + ---Logs the given message at the hint level (3). + ---@param ... any Message and format arguments. + ---@return boolean written False when nothing was passed or the message was empty, otherwise true. + hint: (...) => @log 3, ... + ---Logs the given message at the debug level (4). + ---@param ... any Message and format arguments. + ---@return boolean written False when nothing was passed or the message was empty, otherwise true. + debug: (...) => @log 4, ... + ---Logs the given message at the trace level (5). + ---@param ... any Message and format arguments. + ---@return boolean written False when nothing was passed or the message was empty, otherwise true. + 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, ... + + ---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, ... + + ---Draws an in-place progress bar, filling it toward the given completion percentage. + ---@param progress? number|boolean Completion percentage from 0 to 100; a falsy value closes the open bar and is the default. + ---@param msg? string Label drawn before the bar when it first opens (default: empty). + ---@param ... any Format arguments substituted into the label. + progress: (progress=false, msg = "", ...) => + if @progressStep and not progress + @logEx nil, "■"\rep(10-@progressStep).."]", true, "" + @progressStep = nil + elseif progress + unless @progressStep + @progressStep = 0 + msg ..= " " if #msg>0 + @logEx nil, "#{msg}[", false, nil, nil, ... + step = math.floor(progress * 0.1 + 0.5) + @logEx nil, "■"\rep(step-@progressStep), false, "" + @progressStep = step + + -- taken from https://github.com/TypesettingCartel/Aegisub-Motion/blob/master/src/Log.moon + ---Logs a table dump (or scalar value) at the specified level. + ---@param item any 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 + + count, tablecount = 1, 1 + + result = { "{ @#{tablecount}" } + seen = { [item]: tablecount } + 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 + key = "##{key}" + if "table" == type value + unless seen[value] + tablecount += 1 + seen[value] = tablecount + count += 1 + result[count] = space .. "#{key}: { @#{tablecount}" + recurse value, space .. " ", depth + count += 1 + result[count] = space .. "}" + else + count += 1 + result[count] = space .. "#{key}: @#{seen[value]}" + + else + if "string" == type value + value = ("%q")\format value + + count += 1 + result[count] = space .. "#{key}: #{value}" + + recurse item, " " + result[count+1] = "}" + + return table.concat(result, "\n")\gsub "%%", "%%%%" + + ---Displays the given error in a modal dialog with a Close button, then aborts the running script. + ---@param errorMessage string Message shown in the dialog. + windowError: ( errorMessage ) -> + aegisub.dialog.display { { class: "label", label: errorMessage } }, { "&Close" }, { cancel: "&Close" } + aegisub.cancel! + + + ---Deletes log files that exceed the configured age, size, or count limits, keeping the newest. + ---@param doWipe boolean When true, delete every matching log file regardless of the limits. + ---@param maxAge? number Maximum file age in seconds before deletion (default: the logger's maxAge). + ---@param maxSize? number Maximum combined size in bytes before older files are deleted (default: the logger's maxSize). + ---@param maxFiles? number Maximum number of files to keep (default: the logger's maxFiles). + ---@return integer deleted Number of files removed (0 when the log directory does not exist yet). + ---@return integer deletedSize Combined size in bytes of the removed files. + ---@return integer total Total number of matching log files found. + ---@return integer totalSize Combined size in bytes of all matching files. + trimFiles: (doWipe, maxAge = @maxAge, maxSize = @maxSize, maxFiles = @maxFiles) => + files, totalSize, deletedSize, now, f = {}, 0, 0, os.time!, 0 + + dir = aegisub.decode_path @logDir + -- nothing to trim if the log directory hasn't been created yet + return 0, 0, 0, 0 unless lfs.attributes dir, "mode" + + for file in lfs.dir dir + fullPath = "#{dir}/#{file}" + attr = lfs.attributes fullPath + if type(attr) == "table" and attr.mode == "file" and file\find @fileMatch + f += 1 + 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 + + for i, file in ipairs files + totalSize += file.size + if doWipe or kept > maxFiles or totalSize > maxSize or file.modified+maxAge < now + deletedSize += file.size + os.remove file.path + else + kept += 1 + return total-kept, deletedSize, total, totalSize + + ---Returns a human-readable type name for the given value. + ---@param val any Value whose type to describe. + ---@return string type The Lua type name, or "<ClassName> object" for a DependencyControl class instance. + @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 54% rename from modules/DependencyControl/ModuleLoader.moon rename to modules/l0/DependencyControl/ModuleLoader.moon index e1f6738..963215b 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/l0/DependencyControl/ModuleLoader.moon @@ -1,172 +1,204 @@ --- 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" +SemanticVersion = require "l0.DependencyControl.SemanticVersion" +ModuleProvider = require "l0.DependencyControl.ModuleProvider" +Common = require "l0.DependencyControl.Common" +-- required lazily because a load-time require of UpdateTask would be circular +local UpdateTask + +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.]] + } + } + + ---Formats a single module's version-error line for a load-error summary. + ---@param name string The module's display name. + ---@param reqVersion? string|integer The required version, shown in the message when present. + ---@param url? string The module's feed/download URL, appended when present. + ---@param reason? string The failure reason shown for the module. + ---@param ref? table The installed module's version record; when given, formats the outdated template instead of the missing one. + ---@return string errorLine The formatted one-line error entry. + @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 = SemanticVersion\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 + + ---Registers a placeholder entry for this module in the global module registry so a circular + ---dependency can resolve this module while it is still loading. No-op for non-module scripts. + ---@return boolean|nil registered True if a dummy was registered, false if one already existed, nil if this isn't a module. + @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 + + ---Removes this module's placeholder registry entry if it is still a dummy (not yet replaced by the + ---real module). No-op for non-module scripts. + ---@return boolean|nil removed True if a dummy entry was removed, false if none was present, nil if this isn't a module. + @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 + + ---Loads a single required module, storing the result in `mdl._ref` and running its + ---DependencyControl initializer. On failure, sets `mdl._missing` when the module wasn't found or + ---`mdl._error` with the load error. + ---@param mdl table The module descriptor to load; mutated in place with the result and error flags. + ---@param usePrivate? boolean Load this script's private copy (namespaced under its own name) instead of the shared module. + ---@param reload? boolean Discard any cached reference and load the module afresh. + ---@return table? ref The loaded module reference, or nil on failure. + @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<string, boolean> 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 + ._updated, ._missing = true, false + else + UpdateTask or= require "l0.DependencyControl.UpdateTask" + unless code == UpdateTask.UpdateStatus.SkippedOptional + ._reason = @@updater.__class.getUpdaterErrorMsg code, .name or .moduleName, Common.ScriptType.Module, 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.__class.getUpdaterErrorMsg code, .name or .moduleName, Common.ScriptType.Module, false, extErr + + missing, outdated, moduleError = {}, {}, {} + for mdl in *modules + with mdl + name = .name or .moduleName + if ._missing and not .optional + 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 Common.makeSet 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..7fed396 --- /dev/null +++ b/modules/l0/DependencyControl/ModuleProvider.moon @@ -0,0 +1,156 @@ +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 '%s' 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 or "?", errMsg + +-- Resolves DependencyControl from package.loaded rather than require()ing it, because an alias can be +-- pulled in during DepCtrl's own bootstrap where a require-back would cycle, and 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 + +-- Returns a loader for a registered alias, or nil for an unregistered name. +-- 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 + + ---Builds a stack traceback string with full, untruncated source paths, rewriting known Aegisub + ---directory prefixes back to their `?user`/`?data`/`?temp` aliases. Suitable as an xpcall handler. + ---@param msg? string A message, typically the error, prepended to the traceback. + ---@return string traceback The formatted traceback, one frame per line. + @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..9694fd5 --- /dev/null +++ b/modules/l0/DependencyControl/NamedSemaphore.moon @@ -0,0 +1,133 @@ +ffi = require "ffi" +Finalizer = require "l0.DependencyControl.Finalizer" + +local formatName, openImpl, isOpenImpl, tryLockImpl, lockImpl, unlockImpl, closeImpl +local pid, isAvailable, unlinkAtExit + +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 + + -- Names to unlink when this Lua state tears down (≈ process exit for the main state). We deliberately + -- do NOT unlink when an individual instance is collected: unlinking a name another holder still owns + -- would let a later sem_open create a *separate* semaphore, so two holders could each believe they hold + -- the lock. Deferring the unlink to teardown keeps the name valid for the whole process while still + -- cleaning it up (so a reused pid can't inherit a stale semaphore) on a clean exit. The finalizer must be + -- kept alive for the module's lifetime (anchored on the class below) or it would be collected — and fire + -- the unlink — early. + namesToUnlink = {} + unlinkAtExit = Finalizer.create -> pcall(-> ffi.C.sem_unlink name) for name in pairs namesToUnlink + + -- 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 + namesToUnlink[name] = true if unlink -- unlinked at state teardown, not now (see above) + + +---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 + + -- anchor the teardown-unlink finalizer to the class so it lives as long as the module (nil on Windows) + @__unlinkFinalizer = unlinkAtExit + + ---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 Lua state tears down (not when + ---this instance is collected — that would break a name another holder still owns). + ---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 + Finalizer.guard @, -> closeImpl name, handle, unlink + + ---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..ebc0c0c --- /dev/null +++ b/modules/l0/DependencyControl/Record.moon @@ -0,0 +1,563 @@ +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" +SemanticVersion = require "l0.DependencyControl.SemanticVersion" +Accessors = require "l0.DependencyControl.Accessors" +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +FileCache = require "l0.DependencyControl.FileCache" +configSchema = require "l0.DependencyControl.config-schema" + +-- 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 + + +---A provided-module alias: the `require` name this module satisfies plus optional metadata. In a +---`provides` list a bare string is shorthand for `{name = <string>}`; records and feeds store the +---normalized table form. `version` is the version of the provided module the provider satisfies +---(reserved — not yet consulted during resolution, which uses the provider's own release version). +---@alias ModuleAlias { name: string, version?: string } + +---A required-module dependency. A bare `require` name is shorthand for a version-agnostic +---requirement. The table form below adds a version floor and a source to fetch the module from +---when it is missing. +---@class RequiredModuleSpec +---@field [1]? string The module namespace (alternative to `moduleName`). +---@field moduleName? string The module namespace, as used in `require`. +---@field version? string|number Minimum version; the module must carry a compatible DependencyControl version record. +---@field url? string Where the module can be downloaded, shown to the user in error messages. +---@field feed? string Update feed used to fetch the module when it is missing. +---@field optional? boolean When true a missing module is not an error, but a module that is found is still version-checked. +---@field name? string Friendly name used in error messages. + +---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]? (string|RequiredModuleSpec)[] 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 "<namespace>.json"). +---@field virtual? boolean Mark as a not-yet-installed placeholder record. +---@field recordType? RecordType A Common.RecordType value (default Managed). +---@field requiredModules? (string|RequiredModuleSpec)[] Required module specs (alternative to the positional list). +---@field provides? (string|ModuleAlias)[] Module aliases this module satisfies for `require` (bare strings are normalized to ModuleAlias tables). +---@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 +---@field semanticVersion SemanticVersion This record's version as a value object (the canonical store). +---@field version integer This record's version as a packed integer; assignable from a string, packed integer, or SemanticVersion. +class Record + 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", + -- version is a packed integer at runtime but a semver string in the config, so loadConfig and + -- writeConfig convert it explicitly + scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", + "requiredModules", "recordType", "provides"} + } + + ---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<string, Record> records + @getAllRegisteredRecords = => {ns, record for ns, record in pairs recordsByNamespace} + + init = => + FileOps.mkdir @depConf.file, true + @loadGlobalConfig! + {:logging, :paths} = @config.c + @logger = Logger { fileBaseName: constants.DEPCTRL_SHORT_NAME, fileSubName: script_namespace, prefix: "[#{constants.DEPCTRL_SHORT_NAME}] ", + toFile: logging.toFile, defaultLevel: logging.defaultLevel, + maxAge: logging.maxAge, maxSize: logging.maxSize, maxFiles: logging.maxFiles, + logDir: paths.log } + + @updater = Updater script_namespace, @config, @logger + @configDir = paths.config + + FileOps.mkdir aegisub.decode_path @configDir + @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 + + -- createDummyRef below can expose this record before its real version is parsed, so set a valid one now + @semanticVersion = SemanticVersion.fromPacked 0 + + Common.addDefaults args, { + readGlobalScriptVars: true + saveRecordToConfig: true + } + + {@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= Common.RecordType.Managed + -- {name, description, process, validate, isActive} of each registered macro, keyed by name + @registeredMacros = {} + -- also support name key (as used in configuration) for required modules + @requiredModules or= args.requiredModules + + if @moduleName + @namespace = @moduleName + @name = name or @moduleName + @scriptType = Common.ScriptType.Module + ModuleLoader.createDummyRef @ unless @virtual or @recordType == Common.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 == Common.RecordType.Managed, msgs.new.badRecordError\format msgs.new.badRecord.noUnmanagedMacros + assert @namespace, msgs.new.badRecordError\format msgs.new.badRecord.missingNamespace + @scriptType = Common.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 == Common.RecordType.Unmanaged or @validateNamespace!, + msgs.new.badRecord.badNamespace\format @namespace + + @configFile = configFile or "#{@namespace}.json" + @automationDir = Common\getAutomationDir @scriptType + @testDir = Common\getTestDir @scriptType + packed, err = SemanticVersion\toPacked version + assert packed, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err + @semanticVersion = SemanticVersion.fromPacked packed + + @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 + @loadGlobalConfig = => + if @config + @config\load! + else + @config = ConfigView\get @depConf.file, {"config"}, configSchema.sections, @logger, false, + {schemaId: configSchema.CONFIG_SCHEMA_ID_CURRENT, migrate: configSchema.migration.migrate} + + ---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, + { Common.ScriptTypeSection[@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 + -- version isn't in scriptFields, so read it explicitly + @version = @config.c.version if @config.c.version != nil + + 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, Common.terms.scriptType.singular[@scriptType] + @config\import @, @@depConf.scriptFields, false, true + -- version isn't a scriptField, so store it as a semver string + @config.c.version = tostring @semanticVersion + success, errMsg = @config\save! + + assert success, msgs.writeConfig.error\format errMsg + + + ---@deprecated Use `SemanticVersion.toPacked`. + ---Converts a version to its packed-integer form, defaulting to this record's version. + ---@param value? number|string Version to convert (default: this record's version). + ---@return number? versionNumber nil on an invalid version string. + ---@return string? err + getVersionNumber: (value = @version) => SemanticVersion\toPacked value + + ---@deprecated Use `SemanticVersion.toString`. + ---Converts a version to its string form, defaulting to this record's version. + ---@param version? number|string Version to convert (default: this record's version). + ---@return string? versionString nil on an invalid version. + ---@return string? err + getVersionString: (version = @version) => SemanticVersion\toString version + + + ---Resolves this record's external config file path. Config files share one directory so + ---they stay discoverable to other scripts through the DependencyControl config file. + ---@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 + + ---Returns a shared, persistent on-disk cache for this script, under the user's configured cache location. + ---It lives at `<the configured cache dir>/<this script's namespace>/<name>`, so each script gets its own + ---namespaced caches and honors the DependencyControl config. Repeated calls for the same name share one instance. + ---@param name string A short name for the cache's purpose (e.g. "thumbnails"). + ---@param opts? FileCacheOptions Default cache options; applied only when the cache is first created. + ---@return FileCache + getFileCache: (name, opts) => + FileCache.get @@config.c.paths.cache, @namespace, name, opts + + ---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 SemanticVersion\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 == Common.RecordType.Unmanaged or @scriptType != Common.ScriptType.Module + mdlConfig = @@config\getSectionHandler Common.ScriptTypeSection[Common.ScriptType.Module] + pattern = "^#{Common.escapePattern @namespace}%." + return [mdl for mdl, _ in pairs mdlConfig.c when mdl\match pattern], mdlConfig + + ---Loads or updates required modules and returns their references. + ---@param modules? (string|RequiredModuleSpec)[] 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; an absent optional module comes back as nil. + 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 Extra arguments forwarded to the suite's import function (see UnitTestSuite for its full signature). + 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 + + -- The test import receives the subject under test first: + -- modules hand over their own ref + -- automation scripts hand over their currently registered macros + if @moduleName + success, errMsg = pcall @tests\import, @ref, modules, ... + else + success, errMsg = pcall @tests\import, @registeredMacros, modules, ... + + if success + @testSuiteInitialized = true + else + @testSuiteInitializeError = errMsg + @@logger\warn "Error initializing test suite for #{Common.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 == Common.ScriptType.Automation + + ---Finalizes module registration and swaps dummy module refs for real refs. Call it in place of + ---returning the module. Modules registered this way may depend on each other circularly, provided + ---they don't use each other during construction. + ---@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. A `customMenu` property in the script's config overrides the + ---macro's menu location; it is a user-owned setting, so scripts must not change it without consent. + ---@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 + + -- record the unhooked process so this script's test suite can drive the macro without triggering an update check + @registeredMacros[name] = {:name, :description, :process, :validate, :isActive} + + ---Registers multiple macros declared in table form. + ---@param macros? table[] Macro definitions, each an argument list for registerMacro. + ---@param submenuDefault? string|boolean Default submenu value applied when a macro omits it (default true). + ---@param testExports? table Internals to expose to this record's DepUnit test suite, forwarded to its test import function. + registerMacros: (macros = {}, submenuDefault = true, testExports) => + @registerTests testExports + 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) + + version: Accessors.property + get: => @semanticVersion\toPacked! + set: (value) => + packed, err = SemanticVersion\toPacked value + error err, 0 unless packed + @semanticVersion = SemanticVersion.fromPacked packed + + ---Parses and sets this record's semantic version without raising on invalid input. + ---@param version number|string + ---@return number? version The parsed integer version, or nil on error. + ---@return string? err + setVersion: (version) => + packed, err = SemanticVersion\toPacked version + return nil, err unless packed + @semanticVersion = SemanticVersion.fromPacked packed + return packed + + ---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 == Common.RecordType.Unmanaged + return nil, msgs.uninstall.noVirtualOrUnmanaged\format @virtual and "virtual" or "unmanaged", + Common.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 = "^#{Common.escapePattern name}" + dir = "#{@automationDir}/#{nsp\gsub '%.', '/'}" + else + pattern = "^#{Common.escapePattern @namespace}" + dir = @automationDir + + lfs.chdir dir + for file in lfs.dir dir + mode, path = FileOps.attributes file, "mode" + -- a file must be "<stem>.<ext>" and a module directory exactly "<stem>", so a + -- sibling package sharing the name prefix never falls into the recursive delete + currPattern = 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 + +-- wire the computed `version` accessor (returns Record, so the module still yields the class) +Accessors.install Record diff --git a/modules/l0/DependencyControl/ScriptTargetFilter.moon b/modules/l0/DependencyControl/ScriptTargetFilter.moon new file mode 100644 index 0000000..53d7ff3 --- /dev/null +++ b/modules/l0/DependencyControl/ScriptTargetFilter.moon @@ -0,0 +1,80 @@ +Common = require "l0.DependencyControl.Common" + +-- fresh copy of all script-type values, so the in-place sort below can't mutate the Enum's own list +scriptTypeList = [v for v in *Common.ScriptType.values] +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<ScriptType, true | { include?: string[], exclude?: string[] }> 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 ScriptType + ---@return table rule + ruleFor: (scriptType) => + @rules[scriptType] or= {include: {}, exclude: {}} + @rules[scriptType] + + ---Includes a single namespace of the given script type. + ---@param scriptType ScriptType + ---@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? ScriptType 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 ScriptType + ---@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 ScriptType[] 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 ScriptType + ---@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..ea49ce1 --- /dev/null +++ b/modules/l0/DependencyControl/ScriptUpdateRecord.moon @@ -0,0 +1,153 @@ +Logger = require "l0.DependencyControl.Logger" +Common = require "l0.DependencyControl.Common" +SemanticVersion = require "l0.DependencyControl.SemanticVersion" + +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<string, FeedChannelData> Available update channels keyed by channel name. +---@field changelog? table<string, string|string[]> 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<string, string> Named registry of other feed URLs for cross-feed references. +---@field macros table<string, FeedScriptData> Automation scripts indexed by namespace. +---@field modules table<string, FeedScriptData> 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 ScriptType + ---@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 (Common.makeSet @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 = SemanticVersion\toPacked @version + minVer = SemanticVersion\toPacked minVer + + changelog = {} + for ver, entry in pairs @changelog + verNum = SemanticVersion\toPacked ver + -- skip a malformed changelog version key. feed changelog keys aren't schema-validated, so toPacked + -- returns false for them, and toString(false) or comparing false >= minVer would otherwise raise + continue unless verNum + if verNum >= minVer and verNum <= maxVer + changelog[#changelog+1] = {verNum, SemanticVersion\toString(verNum), entry} + + return "" if #changelog == 0 + table.sort changelog, (a,b) -> a[1]>b[1] + + msg = {msgs.changelog.header\format @name, SemanticVersion\toString(@version), @released or "<no date>"} + 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/SemanticVersion.moon b/modules/l0/DependencyControl/SemanticVersion.moon new file mode 100644 index 0000000..8a08212 --- /dev/null +++ b/modules/l0/DependencyControl/SemanticVersion.moon @@ -0,0 +1,508 @@ +Enum = require "l0.DependencyControl.Enum" +Common = require "l0.DependencyControl.Common" + +SemanticVersion = nil + +---@alias SemverPrecision +---| "major" +---| "minor" +---| "patch" +---| "range" # compare `b` as an npm-style version range string instead of as a version + +NPM_RANGE_TOKEN_OR = "||" +NPM_RANGE_TOKEN_LT = "<" +NPM_RANGE_TOKEN_LTE = "<=" +NPM_RANGE_TOKEN_GT = ">" +NPM_RANGE_TOKEN_GTE = ">=" +NPM_RANGE_TOKEN_EQ = "=" +NPM_RANGE_TOKEN_TILDE = "~" +NPM_RANGE_TOKEN_RUBY_PESSIMISTIC = NPM_RANGE_TOKEN_TILDE .. NPM_RANGE_TOKEN_GT -- "~>", an alias for "~" (see parseComparator) +NPM_RANGE_TOKEN_CARET = "^" +NPM_RANGE_TOKEN_WILDCARD_X_UPPER = "X" +NPM_RANGE_TOKEN_WILDCARD_X_LOWER = "x" +NPM_RANGE_TOKEN_WILDCARD_STAR = "*" +NPM_RANGE_TOKEN_HYPHEN = "-" + +npmRangeWildcardTokens = { + [NPM_RANGE_TOKEN_WILDCARD_X_UPPER]: true + [NPM_RANGE_TOKEN_WILDCARD_X_LOWER]: true + [NPM_RANGE_TOKEN_WILDCARD_STAR]: true +} + +semverFields = {"major", "minor", "patch"} + +ComparisonOperator = Enum "ComparisonOperator", { + GT: ">" + GTE: ">=" + LT: "<" + LTE: "<=" + EQ: "=" +} +Op = ComparisonOperator + +---A version comparison operator (the value of a ComparisonOperator enum member). +---@alias ComparisonOperator +---| ">" # greater than +---| ">=" # greater than or equal +---| "<" # less than +---| "<=" # less than or equal +---| "=" # equal + +---A semantic version parsed into its specified components; an absent or wildcard (`x`/`*`) component is nil. +---@class PartialVersion +---@field major? integer +---@field minor? integer +---@field patch? integer + +---A single version comparator: an operator and the encoded version it compares against. +---@class SemverComparator +---@field op ComparisonOperator +---@field num integer The encoded version (see encodeVersion) the operator compares against. + +---A half-open version interval [min, max): min inclusive, max exclusive (both encoded versions). +---@class SemverInterval +---@field min integer Inclusive lower bound (encoded version). +---@field max integer Exclusive upper bound (encoded version). + +msgs = { + toPacked: { + 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." + } + range: { + badType: "Version range must be a string, got a %s." + invalidVersion: "Invalid version '%s' in range." + } + new: { + badComponent: "Invalid %s version component: expected an integer in [0, 255], got %s." + } + fromPacked: { + badType: "Packed version must be an integer in [0, 0xFFFFFF], got %s." + } + parse: { + badType: "Version to parse must be a string, got a %s." + } +} + +---Encodes a semantic version's major/minor/patch components into a single integer. +---@param major? integer The major version (0-255). +---@param minor? integer The minor version (0-255). +---@param patch? integer The patch version (0-255). +---@return integer encoded The encoded version. +encodeVersion = (major, minor, patch) -> + bit.lshift(major or 0, 16) + bit.lshift(minor or 0, 8) + (patch or 0) + +-- One past the highest representable version (255.255.255); the exclusive upper bound of an +-- unbounded-above interval. Exclusive upper bounds from range expansion never exceed this. +SEMVER_RANGE_MAX_EXCLUSIVE = encodeVersion 256, 0, 0 + +---Parses a (possibly partial) version into a table holding only the specified, non-wildcard components. +---Absent or wildcard components become nil. +---@param str string The version string to parse (e.g "1", "1.2", "1.2.3", "1.x", "*") +---@return PartialVersion? parsed The parsed version, or nil on error. +---@return string? err Error message if parsing failed. +parsePartialVersion = (str) -> + str = (Common.trim str)\gsub "^[vV]", "" + return {} if str == "" or npmRangeWildcardTokens[str] + components = [c for c in str\gmatch "[^%.]+"] + return nil, msgs.range.invalidVersion\format str if #components == 0 or #components > 3 + + parsed = {} + for i, component in ipairs components + unless npmRangeWildcardTokens[component] + n = tonumber component + return nil, msgs.range.invalidVersion\format str unless n and n % 1 == 0 and n >= 0 and n <= 255 + parsed[semverFields[i]] = n + parsed + +---Expands a bare version ("1.2") or partial version (1.2.x) with wildcards converted to nil into its equivalent X-range +---comparator list. +---@param v PartialVersion The parsed version (possibly partial; wildcard components are nil). +---@return SemverComparator[] comparators The expanded comparator list equivalent to the original version. +xRangeComparators = (v) -> + if v.major == nil + {{op: Op.GTE, num: 0}} + elseif v.minor == nil + {{op: Op.GTE, num: encodeVersion v.major, 0, 0}, {op: Op.LT, num: encodeVersion v.major + 1, 0, 0}} + elseif v.patch == nil + {{op: Op.GTE, num: encodeVersion v.major, v.minor, 0}, {op: Op.LT, num: encodeVersion v.major, v.minor + 1, 0}} + else + {{op: Op.EQ, num: encodeVersion v.major, v.minor, v.patch}} + +---Expands a tilde-range version into its equivalent comparator list. Allows patch-level changes if a +---minor version is specified, otherwise minor-level changes. +---@param v PartialVersion The parsed version (possibly partial; wildcard components are nil). +---@return SemverComparator[] comparators The expanded comparator list equivalent to the original tilde-range version. +tildeComparators = (v) -> + lower = encodeVersion v.major or 0, v.minor or 0, v.patch or 0 + upper = v.minor != nil and encodeVersion(v.major or 0, v.minor + 1, 0) or encodeVersion((v.major or 0) + 1, 0, 0) + {{op: Op.GTE, num: lower}, {op: Op.LT, num: upper}} + +---Expands a caret-range version into its equivalent comparator list. Allows changes that do not modify the +---left-most non-zero digit in the {major, minor, patch} sequence. +---@param v PartialVersion The parsed version (possibly partial; wildcard components are nil). +---@return SemverComparator[] comparators The expanded comparator list equivalent to the original caret-range version. +caretComparators = (v) -> + major = v.major or 0 + lower = encodeVersion major, v.minor or 0, v.patch or 0 + upper = if v.minor == nil + encodeVersion major + 1, 0, 0 + elseif v.patch == nil + major == 0 and encodeVersion(0, v.minor + 1, 0) or encodeVersion(major + 1, 0, 0) + elseif major == 0 + v.minor == 0 and encodeVersion(0, 0, v.patch + 1) or encodeVersion(0, v.minor + 1, 0) + else + encodeVersion major + 1, 0, 0 + {{op: Op.GTE, num: lower}, {op: Op.LT, num: upper}} + +---Expands an explicit-operator comparator over a possibly-partial version into concrete {op, num} comparator(s). +---A fully-specified version (e.g. ">1.2.3") passes straight through whereas a partial one is normalized the way +---the operator dictates (e.g. ">1" => ">=2.0.0","<=1.2" => "<1.3.0"). +---@param op ComparisonOperator The operator (e.g. ">", "<=", "="). +---@param v PartialVersion The parsed version (possibly partial; wildcard components are nil). +---@return SemverComparator[] comparators The expanded comparator(s) equivalent to the original partial comparator. +operatorComparators = (op, v) -> + -- "=" is just an X-range (e.g. `=1.2` is the same as `1.2.x`) so it is delegated. + return xRangeComparators v if op == NPM_RANGE_TOKEN_EQ + + major, minor, patch = v.major, v.minor, v.patch + -- A wildcard major (e.g. ">*", ">=x") applies the operator to "any version at all". ">" and "<" can never hold + -- (no version is greater/less than every version) while ">=" and "<=" always hold. + return (op == NPM_RANGE_TOKEN_GT or op == NPM_RANGE_TOKEN_LT) and {{op: Op.LT, num: 0}} or {{op: Op.GTE, num: 0}} if major == nil + if minor == nil or patch == nil + switch op + when NPM_RANGE_TOKEN_GT then return {{op: Op.GTE, num: minor == nil and encodeVersion(major + 1, 0, 0) or encodeVersion(major, minor + 1, 0)}} + when NPM_RANGE_TOKEN_LTE then return {{op: Op.LT, num: minor == nil and encodeVersion(major + 1, 0, 0) or encodeVersion(major, minor + 1, 0)}} + when NPM_RANGE_TOKEN_LT then return {{op: Op.LT, num: encodeVersion major, minor or 0, 0}} + when NPM_RANGE_TOKEN_GTE then return {{op: Op.GTE, num: encodeVersion major, minor or 0, 0}} + {{op: op, num: encodeVersion major, minor, patch}} + +---Takes two versions `a` and `b` extracted from a hyphen range and returns a pair comparator tables +---representing the equivalent range. +---@param aVersion PartialVersion The left-hand side of the hyphen range. +---@param bVersion PartialVersion The right-hand side of the hyphen range. +---@return SemverComparator[] comparators The pair of comparators ({lower, upper}) representing the hyphen range. +hyphenComparators = (aVersion, bVersion) -> + lower = {op: Op.GTE, num: encodeVersion aVersion.major or 0, aVersion.minor or 0, aVersion.patch or 0} + upper = if bVersion.minor == nil + {op: Op.LT, num: encodeVersion((bVersion.major or 0) + 1, 0, 0)} + elseif bVersion.patch == nil + {op: Op.LT, num: encodeVersion(bVersion.major, bVersion.minor + 1, 0)} + else + {op: Op.LTE, num: encodeVersion(bVersion.major, bVersion.minor, bVersion.patch)} + {lower, upper} + +---Parse a single comparator token into a list of comparator tables. +---@param token string The comparator token (e.g. ">=1.2.3", "~1.2", "1.2.x"). +---@return SemverComparator[]? comparators The parsed comparators, or nil on error. +---@return string? err Error message if parsing failed. +parseComparator = (token) -> + head = token\sub 1, 1 + if head == NPM_RANGE_TOKEN_TILDE + -- "~>" is an undocumented but supported alias for "~" - a RubyGems "pessimistic version constraint" + prefix = token\sub(1, 2) == NPM_RANGE_TOKEN_RUBY_PESSIMISTIC and NPM_RANGE_TOKEN_RUBY_PESSIMISTIC or NPM_RANGE_TOKEN_TILDE + v, err = parsePartialVersion token\sub(#prefix + 1) + return nil, err unless v + return tildeComparators v + if head == NPM_RANGE_TOKEN_CARET + v, err = parsePartialVersion token\sub 2 + return nil, err unless v + return caretComparators v + + op = nil + if token\sub(1, 2) == NPM_RANGE_TOKEN_GTE or token\sub(1, 2) == NPM_RANGE_TOKEN_LTE + op, token = token\sub(1, 2), token\sub 3 + elseif head == NPM_RANGE_TOKEN_GT or head == NPM_RANGE_TOKEN_LT or head == NPM_RANGE_TOKEN_EQ + op, token = head, token\sub 2 + v, err = parsePartialVersion token + return nil, err unless v + op and operatorComparators(op, v) or xRangeComparators v + +---Parse one ||-separated group of whitespace-separated comparators into a list of comparator tables. +---@param groupStr string The comparator group string. +---@return SemverComparator[]? comparators The parsed comparators, or nil on error. +---@return string? err Error message if parsing failed. +parseComparatorSet = (groupStr) -> + groupStr = Common.trim groupStr + return {{op: Op.GTE, num: 0}} if groupStr == "" + + fromStr, toStr = groupStr\match "^(.-)%s+%-%s+(.+)$" + if fromStr + fromVer, errFrom = parsePartialVersion fromStr + return nil, errFrom unless fromVer + toVer, errTo = parsePartialVersion toStr + return nil, errTo unless toVer + return hyphenComparators fromVer, toVer + + comparators = {} + for token in groupStr\gmatch "%S+" + parts, err = parseComparator token + return nil, err unless parts + comparators[#comparators + 1] = comparator for comparator in *parts + comparators + +---Reduces an AND-list of comparators to a single half-open interval [min,max). +---@param comparators SemverComparator[] +---@return SemverInterval interval The reduced interval (min inclusive, max exclusive). +reduceToInterval = (comparators) -> + min, max = 0, SEMVER_RANGE_MAX_EXCLUSIVE + for comp in *comparators + switch comp.op + when Op.GTE then min = math.max min, comp.num + when Op.GT then min = math.max min, comp.num + 1 + when Op.LTE then max = math.min max, comp.num + 1 + when Op.LT then max = math.min max, comp.num + when Op.EQ then min, max = math.max(min, comp.num), math.min(max, comp.num + 1) + {:min, :max} + + +---Splits a range string into `||` groups, reduces each to an interval, and drops empty ones. The +---public entry point (with the type guard and full syntax docs) is SemanticVersion.parseRange. +---@param range string The version range. +---@return SemverInterval[]? intervals The range's non-empty intervals, or nil on a malformed range. +---@return string? err Error message on failure. +parseRangeToIntervals = (range) -> + range = Common.trim range + groups, start = {}, 1 + while true + s, e = range\find NPM_RANGE_TOKEN_OR, start, true + if s + groups[#groups + 1] = range\sub start, s - 1 + start = e + 1 + else + groups[#groups + 1] = range\sub start + break + intervals = {} + for groupStr in *groups + comparators, err = parseComparatorSet groupStr + return nil, err unless comparators + interval = reduceToInterval comparators + intervals[#intervals + 1] = interval if interval.min < interval.max + intervals + +---A semantic version value (major.minor.patch) plus the static semantic-versioning utilities. Construct +---one from a version string or from numeric components; instances compare with `<`/`==`, stringify to +---"major.minor.patch", and bump. The static helpers additionally accept an instance wherever they take a +---`number|string` version. Packed integers are an internal encoding, exposed only via `fromPacked`/`toPacked`. +---@class SemanticVersion +---@field major integer The major version (0-255). +---@field minor integer The minor version (0-255). +---@field patch integer The patch version (0-255). +class SemanticVersion + semParts = {{"major", 16}, {"minor", 8}, {"patch", 0}} + + ---@param major integer|string A full version string ("1.2.3"), or the major version (0-255) with the + --- minor and patch supplied as the next two arguments. Raises on an invalid string or component. + ---@param minor? integer The minor version (0-255); ignored when the first argument is a string. + ---@param patch? integer The patch version (0-255); ignored when the first argument is a string. + new: (major, minor, patch) => + if type(major) == "string" + packed, err = @@toPacked major + error err, 0 unless packed + @major, @minor, @patch = bit.rshift(packed, 16) % 256, bit.rshift(packed, 8) % 256, packed % 256 + else + components = {{"major", major or 0}, {"minor", minor or 0}, {"patch", patch or 0}} + for part in *components + unless type(part[2]) == "number" and part[2] % 1 == 0 and part[2] >= 0 and part[2] <= 255 + error msgs.new.badComponent\format(part[1], tostring part[2]), 0 + @major, @minor, @patch = components[1][2], components[2][2], components[3][2] + + ---@return integer packed This version in the internal packed encoding (major<<16 | minor<<8 | patch). + toPacked: => encodeVersion @major, @minor, @patch + + ---Reports whether this version satisfies an npm-style range (see the static parseRange for the syntax). + ---@param range string The version range. + ---@return boolean? satisfies True/false, or nil on a malformed range. + ---@return string? err Error message on a malformed range. + satisfies: (range) => @@satisfiesRange @toPacked!, range + + ---@return SemanticVersion bumped A new version with the major incremented and minor/patch reset to 0. + bumpMajor: => SemanticVersion @major + 1, 0, 0 + ---@return SemanticVersion bumped A new version with the minor incremented and patch reset to 0. + bumpMinor: => SemanticVersion @major, @minor + 1, 0 + ---@return SemanticVersion bumped A new version with the patch incremented. + bumpPatch: => SemanticVersion @major, @minor, @patch + 1 + + __tostring: => "#{@major}.#{@minor}.#{@patch}" + __eq: (other) => @major == other.major and @minor == other.minor and @patch == other.patch + -- coerce both sides through toPacked so a version compares against another instance, a string, or a + -- packed number (self is the left operand, which Lua may hand us as the non-instance in `n < version`) + __lt: (other) => SemanticVersion\toPacked(@) < SemanticVersion\toPacked other + __le: (other) => SemanticVersion\toPacked(@) <= SemanticVersion\toPacked other + + --- Converts a version number, string, or instance to a semantic version string. + ---@param version number|string|SemanticVersion|nil The version as a packed number, a string, an instance, or nil (rendered as "0.0.0"). + ---@param precision? SemverPrecision + ---@return string|nil versionString + ---@return string|nil err + @toString = (version, precision = "patch") => + version, err = @toPacked 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, number, or SemanticVersion instance to a packed integer. + ---@param value string|number|SemanticVersion|nil The version as a string ("1.2.3"), a packed number, an instance, or nil. + ---@return number|false version The packed integer version, or false on error. + ---@return string? err Error message if conversion failed. + @toPacked = (value) => + return value\toPacked! if type(value) == "table" and value.__class == SemanticVersion + 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.toPacked.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.toPacked.overflow\format part[1], tostring value + + version += bit.lshift value, part[2] + version + + else false, msgs.toPacked.badType\format type value + + + ---Builds a version from the internal packed encoding (as returned by toPacked). Raises on a value + ---outside [0, 0xFFFFFF]. Call as a plain static (`SemanticVersion.fromPacked packed`). + ---@param packed integer A packed version in [0, 0xFFFFFF]. + ---@return SemanticVersion version The version the packed integer encodes. + @fromPacked = (packed) -> + unless type(packed) == "number" and packed % 1 == 0 and packed >= 0 and packed <= 0xFFFFFF + error msgs.fromPacked.badType\format(tostring packed), 0 + SemanticVersion bit.rshift(packed, 16) % 256, bit.rshift(packed, 8) % 256, packed % 256 + + ---Parses a version string into an instance without raising, for untrusted input (the constructor raises + ---instead). Call as a plain static (`SemanticVersion.parse str`). + ---@param str string The version string (e.g. "1.2.3"). + ---@return SemanticVersion? version The parsed version, or nil on error. + ---@return string? err Error message if parsing failed. + @parse = (str) -> + return nil, msgs.parse.badType\format(type str) unless type(str) == "string" + packed, err = SemanticVersion\toPacked str + return nil, err unless packed + SemanticVersion.fromPacked packed + + ---Checks whether version `a` is greater than or equal to version `b`, up to the given precision. + ---When `precision` is "range", `b` is instead an npm-style version range string and the result is + ---whether `a` satisfies that range (see satisfiesRange). + ---@param a number|string The first version. + ---@param b number|string The second version, or an npm-style range string when precision is "range". + ---@param precision? SemverPrecision Precision to compare at (default "patch"). + ---@return boolean? result True if a satisfies b, or nil on error. + ---@return number|string masked The masked value of b on success (absent for ranges), or the error message on failure. + @check: (a, b, precision = "patch") => + if type(a) != "number" + a, err = @toPacked a + return nil, err unless a + + return @satisfiesRange a, b if precision == "range" + + if type(b) != "number" + b, err = @toPacked 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 + + ---Parses an npm-style version range into its set of half-open integer intervals `[min,max)` + --- (`min`inclusive, `max` exclusive). Supports the following range syntax: + --- * comparators: `>=1.2.7`, `<=1.2.7`, `>1.2.7`, `<1.2.7`, `=1.2.7` + --- * intersection: `>=1.2.7 <1.3.0` + --- * union: `>=1.2.7 <1.3.0 || >=1.4.0 <2.0.0` + --- * hyphen ranges: `1.2 - 2.3` + --- * X-ranges: `1.x`, `*` + --- * tilde ranges: `~1.2.3` + --- * caret ranges: `^1.2.3` + ---An unsatisfiable range (e.g. `>2 <1`) yields an empty list. + ---Pre-release/build labels are not supported at this time. + ---@param range string The version range. + ---@return SemverInterval[]? intervals The range's intervals, or nil on a malformed range. + ---@return string? err Error message on failure. + @parseRange = (range) => + return nil, msgs.range.badType\format type range unless type(range) == "string" + parseRangeToIntervals range + + ---Reports whether a version satisfies an npm-style semver range. + ---@param version number|string The version to test. + ---@param range string The version range. + ---@return boolean? satisfies True/false, or nil on error (a malformed version or range). + ---@return string? err Error message on failure. + @satisfiesRange = (version, range) => + unless type(version) == "number" + version, err = @toPacked version + return nil, err unless version + intervals, err = @parseRange range + return nil, err unless intervals + for interval in *intervals + return true if version >= interval.min and version < interval.max + false + + ---Reports whether two npm-style version ranges overlap for at least one version. + ---@param rangeA string The first version range. + ---@param rangeB string The second version range. + ---@return boolean? intersect True/false, or nil on error (a malformed range). + ---@return string? err Error message on failure. + @rangesIntersect = (rangeA, rangeB) => + intervalsA, errA = @parseRange rangeA + return nil, errA unless intervalsA + intervalsB, errB = @parseRange rangeB + return nil, errB unless intervalsB + for a in *intervalsA + for b in *intervalsB + return true if math.max(a.min, b.min) < math.min(a.max, b.max) + false + + ---Returns the highest version that satisfies an npm-style range. + ---@param range string The version range. + ---@return number? version The greatest satisfying version, or nil on an unsatisfiable range or a malformed one. + ---@return string? err Error message on a malformed range. + @getRangeMaxVersion = (range) => + intervals, err = @parseRange range + return nil, err unless intervals + highest = nil + for interval in *intervals + highest = interval.max - 1 if not highest or interval.max - 1 > highest + highest + + ---Reports whether `version` is strictly higher than `reference`. Raises on invalid input. + ---Call as a plain static (`SemanticVersion.isHigher a, b`), e.g. as a table.sort comparator. + ---@param version number|string + ---@param reference number|string + ---@return boolean + @isHigher = (version, reference) -> + version, errMsg = SemanticVersion\toPacked version + assert version, errMsg + referenceVersionNumber, errMsg = SemanticVersion\toPacked reference + assert referenceVersionNumber, errMsg + + return version > referenceVersionNumber + + ---Reports whether `version` is strictly lower than `reference`. Raises on invalid input. + ---Call as a plain static (`SemanticVersion.isLower a, b`), e.g. as a table.sort comparator. + ---@param version number|string + ---@param reference number|string + ---@return boolean + @isLower = (version, reference) -> + version, errMsg = SemanticVersion\toPacked version + assert version, errMsg + referenceVersionNumber, errMsg = SemanticVersion\toPacked 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..2d0fcc8 --- /dev/null +++ b/modules/l0/DependencyControl/Stub.moon @@ -0,0 +1,160 @@ + +Common = require "l0.DependencyControl.Common" +Logger = require "l0.DependencyControl.Logger" +Finalizer = require "l0.DependencyControl.Finalizer" + +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." + finalizer: { + 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] = @ + + -- warn if this stub is garbage-collected without restore() having been called + keyRef, logger = key, @logger or @@logger + Finalizer.guard @, -> + unless restored[1] + pcall logger.warn, logger, msgs.finalizer.notRestored, keyRef + + __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 + + ---Reports an assertion failure, routing it through the unit test when one is set or throwing otherwise. + ---@private + ---@param msg string A printf-style format string describing the failure. + ---@param ... any Values interpolated into the format string. + __fail: (msg, ...) => + if @unitTest + @unitTest\assert false, msg, ... + else + error string.format(msg, ...), 2 + + ---Formats a value for inclusion in a failure message. + ---@private + ---@param val any The value to stringify. + ---@return string dump The value's string representation. + __dump: (val) => + return @unitTest.logger\dumpToString val if @unitTest + return tostring val + + ---Asserts the stub was called at least once. + assertCalled: => + @__fail msgs.notCalled unless #@_calls > 0 + + ---Asserts the stub was never called. + assertNotCalled: => + @__fail msgs.wasCalled, #@_calls unless #@_calls == 0 + + ---Asserts the stub was called exactly the given number of times. + ---@param n integer The expected call count. + assertCalledTimes: (n) => + @__fail msgs.wrongCallCount, n, #@_calls unless #@_calls == n + + ---Asserts the stub was called exactly once. + assertCalledOnce: => + @__fail msgs.wrongCallCount, 1, #@_calls unless #@_calls == 1 + + ---Asserts the stub was called exactly once and with the given arguments. + ---@param ... any The arguments expected on the single call. + 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 + + ---Asserts at least one recorded call was made with the given arguments. + ---@param ... any The arguments expected on some call. + assertCalledWith: (...) => + expected = table.pack ... + for call in *@_calls + return if _stubMatch call, expected + @__fail msgs.notCalledWith, #@_calls, @__dump expected + + ---Asserts the most recent call was made with the given arguments. + ---@param ... any The arguments expected on the last call. + 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 + + ---Asserts the nth recorded call was made with the given arguments. + ---@param n integer The 1-based index of the call to check. + ---@param ... any The arguments expected on that call. + 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..996771e --- /dev/null +++ b/modules/l0/DependencyControl/Timer.moon @@ -0,0 +1,89 @@ +ffi = require "ffi" + +local getTime, sleep + +if ffi.os == "Windows" + -- each cdef gets its own pcall so a Sleep redeclaration conflict can't block the QPC/QPF definitions + 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..41f3b10 --- /dev/null +++ b/modules/l0/DependencyControl/UnitTestSuite.moon @@ -0,0 +1,1025 @@ + +Logger = require "l0.DependencyControl.Logger" +Common = require "l0.DependencyControl.Common" +Stub = require "l0.DependencyControl.Stub" +constants = require "l0.DependencyControl.Constants" +Timer = require "l0.DependencyControl.Timer" +Accessors = require "l0.DependencyControl.Accessors" +DependencyControl = nil + +HIDDEN_TEST_EXPORTS_KEY = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}TestExports" +-- unique key a ut\skip call tags its raised error table with, so run() can tell it apart from a real error +SKIP_SENTINEL = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}UnitTestSkipSentinel" + +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: "✓" + failed: "✗" + skipped: "⊘" + 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 + @skipped = false + @ran = true + @stubs = {} + @logStart! + startTime = Timer.getTime! + ok, res = xpcall @f, debug.traceback, @, ... + @duration = Timer.getTime! - startTime + for i = #@stubs, 1, -1 + @stubs[i]\restore! + + if not ok and type(res) == "table" and res[SKIP_SENTINEL] + @skipped, @skipReason, @success = true, res.reason, true + else @success = ok + + @logResult res + return @success, @errMsg + + ---Aborts the running test immediately and marks it skipped with the given reason. + ---Call from within a test to gate it at runtime, e.g. on an unmet environment precondition. + ---@param reason? string Why the test is skipped; shown beside the skip marker and in the report. + skip: (reason) => + error {[SKIP_SENTINEL]: true, :reason} + + ---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) => + -- a test that logged output of its own already closed the "Running test… " line; when so, + -- restate the name beside the marker so it isn't stranded on a line by itself + restate = Logger.isAtLineStart! + indent = restate and @logger.indent or 0 + if @skipped + mark = restate and "#{@@msgs.run.skipped} #{@name}" or @@msgs.run.skipped + mark ..= " (#{@skipReason})" if @skipReason + @logger\logEx nil, mark, nil, nil, indent + elseif @success + ok = restate and "#{@@msgs.run.ok} #{@name}" or @@msgs.run.ok + @logger\logEx nil, ok, nil, nil, indent + 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 + fail = restate and "#{@@msgs.run.failed} #{@name}" or @@msgs.run.failed + @logger\logEx nil, fail, nil, nil, indent + @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 + + ---Replaces a whole module in the require cache with a stub, so a later `require` of that name yields the + ---stub instead (configure it with `\returns`/`\calls`). Restored automatically after the test. + ---Reach for this to swap a lazily-required collaborator wholesale, such as a class the code constructs; to + ---intercept a single method of an already-required class, stub its `__base` field instead. + ---@param name string The module name, as passed to `require`. + ---@return Stub stub Standing in for the module until the test ends. + stubModule: (name) => + @stub package.loaded, name + + ---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<string, [any, string]> 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.notAlmostEquals, 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"), actual + + ---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, "negative", + (includeZero and "included" or "excluded"), actual + + + -- 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: {expected, "table"}, + onlyNumKeys: {onlyNumKeys, "boolean"} + } + + @assert self.itemsEqual(actual, expected, onlyNumKeys), + @@msgs.assert.itemsEqual, "equal", + @@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: {expected, "table"}, + onlyNumKeys: {onlyNumKeys, "boolean"} + } + + @assert self.itemsEqual(actual, expected, onlyNumKeys, nil, true), + @@msgs.assert.itemsEqual, "identical", + @@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 k in pairs tbl + if type(k) == "number" and math.floor(k) == k + 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"} + } + + haystack, target = if caseSensitive + str, needle + else str\lower!, needle\lower! + @assert haystack\find(target, init, true), @@msgs.assert.contains, 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, ... + success = table.remove res, 1 + retCnt = res.n - 1 -- res.n still counts the pcall status flag just removed + 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 + @stubs = {} + @logger\logEx nil, @@msgs.run.setup, false + + startTime = Timer.getTime! + res = table.pack pcall @f, @ + @duration = Timer.getTime! - startTime + @success = table.remove res, 1 + @logResult res[1] + + if @success + @retVals = res + return true, @retVals + + -- a failed setup aborts the class, so its stubs are restored right away + for i = #@stubs, 1, -1 + @stubs[i]\restore! + 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<string, function|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 + -- sort the test names so they run in a stable order when no explicit order is specified + testNames = [name for name in pairs args when "_" != name\sub 1,1] + table.sort testNames + @tests = [UnitTest(name, args[name], @) for name in *testNames] + + ---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) => + -- when the class 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 + byName = {test.name, test for test in *@tests} + tests, seen = {}, {} + for name in *order + @logger\assert byName[name], msgs.run.testNotFound, name + tests[#tests+1] = byName[name] + seen[name] = true + -- a test missing from _order still runs, appended (in name order) after the listed ones: + -- _order sets the run/report order, not which tests run (use ut\skip to skip a test) + tests[#tests+1] = test for test in *@tests when not seen[test.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 ipairs 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 + + for i = #@setup.stubs, 1, -1 + @setup.stubs[i]\restore! + + @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 +---@field failures { classname: string, name: string, error: string, isAssertion: boolean }[] The most recent run's failures as a flat list, each tagged as an assertion failure or an unexpected error (read-only). +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)/<namespace>.(moon|lua)'. + ---@param scriptType ScriptType 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.ScriptTypeSection[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 ScriptType 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 + + ---Reveals a module's private internals to its unit tests by storing them on the module's metatable. + ---@generic T + ---@param mod T The module table (e.g. a class) to expose internals on. + ---@param exports table The internals to reveal to the module's tests. + ---@return T mod The module, unchanged apart from the hidden exports. + @withTestExports = (mod, exports) => + mt = getmetatable mod + unless mt + mt = {} + setmetatable mod, mt + mt[HIDDEN_TEST_EXPORTS_KEY] = exports + mod + + ---Returns the test exports attached to a module via withTestExports, or nil if it has none. + ---@param mod table The required module to read test exports from. + ---@return table? exports + @getTestExports = (mod) => + mt = getmetatable mod + mt and mt[HIDDEN_TEST_EXPORTS_KEY] + + ---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<string, table>|fun(...): table The test classes by name, or a function that returns them. When a function, it receives: + --- * the subject under test: for a module its own ref; for an automation script a map of its registered macros keyed by name, each holding the macro's process/validate/isActive (populated as macros register, so read it inside test bodies, not while building test classes) + --- * dependencies: a numerically keyed table of all modules required by the tested script/module (in order) + --- * extras: any further arguments passed into register/registerMacros — a module's own table, or an automation script's testExports (the internals under test) + --- * a UnitTestSuiteControls handed in as the final argument (e.g. for requireTest) + ---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<string, table> UnitTestClass constructor tables by name. + addClasses: (classes) => + -- add classes in a stable order + classNames = [name for name in pairs classes when "_" != name\sub 1,1] + table.sort classNames + @classes[#@classes+1] = UnitTestClass(name, classes[name], classes[name]._order, @) for name in *classNames + 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 Arguments passed to the import function; a suite controls object is appended after them as its final argument. + ---@return false? result `false` when there was no import function to run, so nothing was loaded; nil once the classes have been imported. + import: (...) => + return false unless @importFunc + + controls = UnitTestSuiteControls @ + args = table.pack ... + args.n += 1 + args[args.n] = controls + classes = (@importFunc) unpack args, 1, args.n + + @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. + ---@private + __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 ipairs classes + success, failed = cls\run abortOnFail + unless success + failedCnt += 1 + -- a failed setup returns the sentinel -1 rather than a list of tests; that case is + -- surfaced separately via collectResults, so only fold in an actual list here + if "table" == type failed + allFailed[#allFailed+1] = test for test in *failed + if abortOnFail + @endTime = os.time! * 1000 + @success = false + @logger.indent -= 1 + @logger\warn msgs.run.aborted, 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 + + failures: Accessors.property + get: => + failures = {} + for suite in *@collectResults! + for c in *suite.cases + if c.failure or c.error + failures[#failures+1] = { + classname: c.classname + name: c.name + error: c.failure or c.error + isAssertion: c.failure != nil + } + return failures + + ---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 + +Accessors.install UnitTestSuite diff --git a/modules/l0/DependencyControl/UpdateFeed.moon b/modules/l0/DependencyControl/UpdateFeed.moon new file mode 100644 index 0000000..9b95e9c --- /dev/null +++ b/modules/l0/DependencyControl/UpdateFeed.moon @@ -0,0 +1,1019 @@ +-- 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" +SemanticVersion = require "l0.DependencyControl.SemanticVersion" +ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" +ScriptTargetFilter = require "l0.DependencyControl.ScriptTargetFilter" +Accessors = require "l0.DependencyControl.Accessors" +JsonSchema = nil + +defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" + +ScriptType = Common.ScriptType + +scriptTypeBySection = { + [Common.ScriptTypeSection[ScriptType.Automation]]: ScriptType.Automation + [Common.ScriptTypeSection[ScriptType.Module]]: ScriptType.Module +} +sectionKeys = {section, true for section in pairs scriptTypeBySection} + +-- 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 a feed sets on a section container (e.g. +-- fileBaseUrls/localFileBasePaths) are skipped: real packages are tables carrying `channels`. +walkPackages = (feed, filter) -> + coroutine.wrap -> + for scriptType in *filter\scriptTypes! + section = Common.ScriptTypeSection[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 + +---Inserts a new file entry into a channel's file list after the last entry of the same type; +---a script entry with no peers lands before the first test entry. +---@param files table[] The channel's file list, mutated in place. +---@param entry table The file entry to insert. +insertFileEntry = (files, entry) -> + entryType = entry.type or "script" + pos = 0 + for i, file in ipairs files + pos = i if (file.type or "script") == entryType + pos = entryType == "script" and 0 or #files if pos == 0 + table.insert files, pos + 1, entry + +---Gives an expanded file record a lazy `localFilePath` property resolved against the feed +---directory. A collapsed per-type path is used as-is; a scalar base path gets the file `name` +---appended. +---@param file table The file record to attach the accessor to. +---@param feedDirPath string The feed directory to resolve against. +---@param localPath string The file's local path (captured from the rolling template state). +---@param isFullPath? boolean Whether localPath is already the complete path; when unset the file name is appended to it. +attachLocalFilePath = (file, feedDirPath, localPath, isFullPath) -> + setmetatable file, __index: (self, key) -> + return unless key == "localFilePath" + return unless localPath + name = rawget self, "name" + return unless isFullPath or name + path = FileOps.validateFullPath isFullPath and localPath or "#{localPath}#{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} + +---The rolling-template values in effect at one channel, captured during local-mode expansion +---so the local path templates can be inverted for file discovery. +---@class UpdateFeedChannelTemplateState +---@field localFileBasePath? string Scalar local base path in effect. +---@field localFileBasePaths? table<string, string> Per-file-type local path templates in effect. +---@field fileBaseUrls? table<string, string> Per-file-type URL templates in effect. + +---Downloaded and expanded update feed data source. +---@class UpdateFeed +---@field url string This feed's source URL, or a file:// URL over its local file when it has no remote URL (read-only). +---@field private __channelTemplateState table<string, table<string, table<string, UpdateFeedChannelTemplateState>>> Captured channel template state, keyed by section, namespace, and channel name. +class UpdateFeed + ---Declares one template variable. A regular template captures its value at a fixed tree + ---depth. A rolling template re-reads its key at every depth, so a value set at any level + ---(feed root, section container, package, channel) rolls down until overridden. + ---@class UpdateFeedTemplateSpec + ---@field depth? integer Tree depth the variable is captured at; absent for rolling templates. + ---@field order? integer Collection order among same-depth templates; a template's own key expansion can only reference lower-order variables of its depth. + ---@field key? string Field the value is read from on the visited object and written back to in expanded form. + ---@field parentKeys? table<string, boolean> Captures the visited object's own key as the value when the key of the object's parent container is in this set. + ---@field selfKeys? table<string, boolean> Captures the visited object's own key as the value when that key is in this set. + ---@field map? table<string, string> Translates a selfKeys-captured key into the variable value; without a matching entry the key itself is used. + ---@field repl? string Lua pattern replaced by `to` in the captured value. + ---@field to? string Replacement for `repl` matches. + ---@field rolling? boolean Marks a rolling template. + ---@field expansionModes? table<UpdateFeedExpansionMode, boolean> Expansion modes the template participates in; absent means all. + ---@field default? string Fallback value when no level of the feed sets the key. + ---@field keyBy? string Field of a record under `keyAt` whose value selects an entry of the rolling map (e.g. a file's `type`). + ---@field keyAt? string Container key whose records trigger the keyed collapse (e.g. "files"). + ---@field keyDefault? string `keyBy` value assumed when the record lacks the field. + ---@field collapseInto? string Name of the scalar rolling template the selected map entry replaces; the scalar stays in place as the fallback when the map has no entry for the key. + + templateData = { + maxDepth: 7 + ---@type table<string, UpdateFeedTemplateSpec> + templates: { + feedName: {depth: 1, order: 1, key: "name" } + baseUrl: {depth: 1, order: 2, key: "baseUrl" } + feed: {depth: 1, order: 3, key: "knownFeeds" } + scriptTypeSection: {depth: 2, order: 1, selfKeys: sectionKeys } + scriptType: {depth: 2, order: 2, selfKeys: sectionKeys, map: scriptTypeBySection } + 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" } + localFileBasePath: {key: "localFileBasePath", rolling: true, expansionModes: {local: true}, default: "./"} + fileBaseUrl: {key: "fileBaseUrl", rolling: true} + localFileBasePaths: { + key: "localFileBasePaths", rolling: true, expansionModes: {local: true}, + keyBy: "type", keyAt: "files", keyDefault: "script", collapseInto: "localFileBasePath" + } + fileBaseUrls: { + key: "fileBaseUrls", rolling: true, + keyBy: "type", keyAt: "files", keyDefault: "script", collapseInto: "fileBaseUrl" + } + } + sourceAt: {} + } + + msgs = { + trace: { + usingCached: "Using cached feed." + downloaded: "Downloaded feed to %s." + } + warn: { + usingStale: "Couldn't refresh feed %s (%s); using the cached copy." + } + 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" + deleted: "removed from dist (marked for deletion): %s" + removeFailed: "couldn't remove %s from dist (%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" + } + findUnlistedFiles: { + channelError: "Skipping file discovery for '%s': %s." + notInvertible: "Skipping file discovery for '%s' (channel '%s'): local path template '%s' must contain @{fileName} and no other unexpanded variables." + badScanPath: "Skipping file discovery for '%s' (channel '%s'): can't resolve scan path '%s' (%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" + addFileHashFailed: "couldn't add discovered file '%s': SHA-1 failed (%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", "fileBaseUrls", "localFileBasePath", "localFileBasePaths", + "vars", "maintainer", "knownFeeds", + "moduleName", + "version", "released", "default", + "optional", + "channels", "changelog", + "files", "requiredModules", "provides", "platforms", + "sha1", "delete", "type", "platform", + "macros", "modules", + "feed", + } + + @defaultConfig = { + dumpExpanded: false + } + + ---Variable-expansion modes for expand(). + ---@alias UpdateFeedExpansionMode + ---| "remote" # Remote (default): expand `fileBaseUrl`/`url` to their download URLs. + ---| "local" # Local: additionally resolve the `localFileBasePath`/`localFilePath` sister fields to on-disk paths (used by the bundler), leaving the remote fields 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 ScriptType A 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 == 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 + + url: Accessors.property get: => @_url or @fileName and "file://#{@fileName}" + + ---Creates an update feed wrapper and optionally fetches feed data. + ---Raises when neither a URL nor a file name is given. + ---@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-fetch settings, normally supplied by `FeedLoader`: `cache` (the on-disk `FileCache`) and `blockPrivateHosts`. + ---@param logger? Logger + new: (@_url, autoLoad = true, @fileName, @config = {}, @logger = defaultLogger) => + error msgs.errors.urlOrFilePathRequired if not @_url and not fileName + Common.addDefaults @config, @@defaultConfig + @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 + + ---Decodes a feed's JSON into its unexpanded working data — null sentinels stripped and the macros/modules/ + ---knownFeeds sections ensured present — alongside the raw null-preserving decode kept for write-back. Shared + ---by loadFile and the feed cache's L1 layer so both agree on the shape. + ---@param content string The raw feed JSON. + ---@return table? unexpandedData The working feed data, before template expansion (nil on a JSON parse error). + ---@return table? raw The pristine decode with `dkjson.null` sentinels intact, for write-back. + @deserialize = (content) -> + ok, raw = pcall dkjson.decode, content, nil, dkjson.null + return nil unless ok and raw + unexpandedData = stripNulls raw + for section in *{ Common.ScriptTypeSection[ScriptType.Automation], + Common.ScriptTypeSection[ScriptType.Module], "knownFeeds" } + unexpandedData[section] or= {} + return unexpandedData, raw + + ---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? data The expanded feed data, or nil on failure. + ---@return string? err Error message on failure. + 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! + -- land the temp file inside downloadPath (joinPath adds the separator) so trimFiles can bound it + rand = "%04X"\format math.random 0, 16^4 - 1 + @fileName or= FileOps.joinPath @config.downloadPath, "#{fileBaseName}#{rand}.json" + @downloader = Downloader nil, {blockPrivateHosts: @config.blockPrivateHosts} + @fileName = fileName if fileName + + dl, err = @downloader\addDownload @url, @fileName + unless dl + return nil, msgs.errors.downloadAdd\format @url, @fileName, err + + @downloader\await! + if dl.error + return nil, msgs.errors.downloadFailed\format @url, @fileName, dl.error + + @logger\trace msgs.trace.downloaded, @fileName + result, loadErr = @loadFile @fileName, expansionMode + -- persist the freshly fetched feed to the on-disk cache (best-effort; a failure just skips caching) + if result and @_url + rawJson = FileOps.readFile @fileName + if rawJson + cacheMeta = @config.cache\put @_url, rawJson, @data.name + @lastFetchedAt = cacheMeta and cacheMeta.cachedAt or @lastFetchedAt + return result, loadErr + + ---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? data The expanded feed data, or nil on failure. + ---@return string? err Error message on failure. + loadFile: (srcPath = @fileName, expansionMode) => + content, err = FileOps.readFile srcPath + return nil, msgs.errors.cantOpen\format err unless content + + unexpandedData, raw = @@.deserialize content + -- luajson errors are useless dumps of whatever, no use to pass them on to the user + return nil, msgs.errors.parse unless unexpandedData + + -- keep the pristine null-preserving decode for write-back (see deserialize) + @rawFeedData = raw + @unexpandedData = unexpandedData + @feedPath = srcPath + @feedDir = srcPath\match("^(.*)[/\\][^/\\]*$") or "." + + return @expand expansionMode + + ---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? feedData The expanded feed data, or nil on failure. + ---@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 + + -- when already loaded, reuse as-is if the expansion mode matches, otherwise re-expand + if @data + return @data if not expansionMode or expansionMode == @expansionMode + return @expand expansionMode + + -- when not yet loaded, fetch a remote feed by its real URL, otherwise load the local file + if @_url + -- the feed cache serves the unexpanded data from its in-memory L1, else the on-disk snapshot. + -- expand copies it into @data for the requested mode + unexpandedData, meta, fresh = @config.cache\get @_url + if unexpandedData and fresh + @unexpandedData = unexpandedData + @lastFetchedAt = meta.cachedAt + @logger\trace msgs.trace.usingCached + return @expand expansionMode + + -- fetch; on failure, fall back to the stale cached data when one exists (offline resilience) + data, err = @fetch nil, expansionMode + return data if data + if unexpandedData + @unexpandedData = unexpandedData + @stale, @lastFetchedAt = true, meta.cachedAt + @logger\warn msgs.warn.usingStale, @_url, err + return @expand expansionMode + return data, err + + return @loadFile @fileName, expansionMode + + ---Expands and returns @data for the requested mode, rebuilt each call from a fresh deep copy of + ---@unexpandedData, so the shared source (the feed cache's L1 memo for this URL) is never mutated by + ---another consumer's expansion. The feed must be loaded (@unexpandedData set) first. + ---@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)) => + @data = Common.deepCopy @unexpandedData + @__channelTemplateState = {} + {: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" + -- [^{}] keeps an outer @{name:key} from matching while an unexpanded variable + -- remains in its key part, so inner variables expand first and the outer + -- lookup resolves on a later pass. Passes repeat until nothing more resolves, + -- bounded so a cyclic vars definition can't spin. + substituted = 0 + substitute = (value) -> + substituted += 1 + value + for _ = 1, maxDepth + substituted = 0 + val = val\gsub "@{([^{}]-):([^{}]-)}", (name, key) -> + source = if type(vars[name]) == "table" then vars[name] + elseif type(rvars[depth+rOff][name]) == "table" then rvars[depth+rOff][name] + value = source and source[key] + substitute value if type(value) == "string" + val = val\gsub "@{([^{}]-)}", (name) -> + value = vars[name] or rvars[depth+rOff][name] + substitute value if value != nil and type(value) != "table" + break if substituted == 0 + val + 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 .selfKeys + vars[name] = .map and .map[parentKey] or parentKey if .selfKeys[parentKey] + elseif 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 and vars[name] + + -- Each key of a root-level `vars` object becomes a template variable; a table value + -- serves the @{name:key} lookup form. Built-in variable names are reserved and skipped. + if depth == 1 and type(obj.vars) == "table" + obj.vars = expandTemplates obj.vars, depth + for name, value in pairs obj.vars + vars[name] = value unless templates[name] + + -- 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 + -- Only write back when the key is already present + obj[templates[name].key] = rvars[depth][name] if obj[templates[name].key] != nil + + -- Collapse each keyed rolling map into its scalar counterpart at the records under + -- its `keyAt` key. Runs after all rolling updates so the collapse target's own roll + -- can't clobber the collapsed value. + collapsedFull = {} + for name,_ in pairs rolling + tmpl = templates[name] + if tmpl.collapseInto and upKey == tmpl.keyAt and type(rvars[depth][name]) == "table" + resolved = rvars[depth][name][obj[tmpl.keyBy] or tmpl.keyDefault] + if resolved + rvars[depth][tmpl.collapseInto] = resolved + collapsedFull[tmpl.collapseInto] = true + + -- file records (array entries under a `files` key) get a lazy localFilePath accessor + if isLocalMode and upKey == "files" + attachLocalFilePath obj, @feedDir, rvars[depth].localFileBasePath, collapsedFull.localFileBasePath + + -- capture each channel's effective rolling state for template inversion (file discovery) + if isLocalMode and upKey == "channels" and vars.namespace and vars.scriptTypeSection + @__channelTemplateState[vars.scriptTypeSection] or= {} + @__channelTemplateState[vars.scriptTypeSection][vars.namespace] or= {} + @__channelTemplateState[vars.scriptTypeSection][vars.namespace][parentKey] = { + localFileBasePath: rvars[depth].localFileBasePath + localFileBasePaths: rvars[depth].localFileBasePaths + fileBaseUrls: rvars[depth].fileBaseUrls + } + + -- expand variables in non-template strings and recurse tables + for k,v in pairs obj + if sourceKeys[k] ~= depth and not rolling[k] and not (depth == 1 and k == "vars") + 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 ScriptType|boolean A 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 + + haveSection, section = Common.ScriptTypeSection\test scriptType + unless haveSection + return nil, msgs.errors.invalidScriptType\format scriptType, + ScriptType\describe nil, (_, v) -> v + + 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 + + ---Returns the modules in this feed whose default channel `provides` the given name. Only the + ---`modules` section is searched (automation scripts can't be `require`d). The feed must be loaded. + ---@param alias string The required module name to find providers for. + ---@return ScriptUpdateRecord[] providers Update records (default channel selected) whose `provides` lists the name. + getProviders: (alias) => + providers = {} + return providers unless @data and @data.modules + for namespace, pkg in pairs @data.modules + continue unless type(pkg) == "table" and pkg.channels + record = ScriptUpdateRecord namespace, pkg, nil, ScriptType.Module, false, @logger + continue unless (record\setChannel!) and record.provides + for entry in *record.provides + name = type(entry) == "table" and entry.name or entry + if name == alias + providers[#providers + 1] = record + break + return providers + + ---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. + ---@private + ---@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. + ---@private + ---@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<version>.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 + + -- Fields a feed `ModuleAlias` may carry (per v0.4.0 of the feed schema). + moduleAliasFields = {"name", "version"} + + ---Projects a list of `provides` entries to feed ModuleAlias tables, keeping only the + ---schema-permissible fields (`name`, `version`). + ---@private + ---@param provides? (string|ModuleAlias)[] + ---@return ModuleAlias[] aliases The normalized alias tables, each carrying `name` plus optional `version`. + @__normalizeModuleAliases = (provides) => + aliases = {} + for entry in *(provides or {}) + aliases[#aliases + 1] = if type(entry) == "table" + {field, entry[field] for field in *moduleAliasFields when entry[field] != nil} + else {name: entry} + return aliases + + ---Updates a package channel's version and dependencies in the raw feed data by loading + ---the package's script and reading its DependencyControl record. + ---@private + ---@param scriptType ScriptType The script type of the package to refresh (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 = SemanticVersion\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 + + -- Mirror the record's provided aliases onto the channel as ModuleAlias tables. + -- A mere reordering or switching between string and table forms doesn't count as a change. + providesSignature = (aliases) -> + Common.getObjectHash {a.name, {k, v for k, v in pairs a when k != "name"} for a in *aliases when a.name} + newProvides = @@__normalizeModuleAliases record.provides + if providesSignature(newProvides) != providesSignature @@__normalizeModuleAliases rawChannel.provides + rawChannel.provides = #newProvides > 0 and newProvides 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. + ---@private + ---@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. + ---@private + ---@param scriptType ScriptType The package's script type (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: ScriptType, channel?: string, changed: boolean, errors: string[] } result + __updatePackage: (scriptType, packageNamespace, channel) => + result = {namespace: packageNamespace, :scriptType, changed: false, errors: {}} + errors = result.errors + + section = Common.ScriptTypeSection[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, addFiles?: boolean } Options. `outPath` false performs a dry run; nil/true defaults to the loaded feed's source path. `addFiles` appends entries (with computed SHA-1s) for on-disk files missing from the targeted channel; the added names are reported per package in `addedFiles`. + ---@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 + + -- Runs after the refresh loop: __refreshFiles pairs raw and expanded file lists by + -- index, so new entries (complete with hashes) may only be appended once it is done. + if opts.addFiles + resultsByPackage = {result.scriptType .. "\0" .. result.namespace, result for result in *stats.packages} + for entry in *(@findUnlistedFiles(filter, opts.channel) or {}) + result = resultsByPackage[entry.scriptType .. "\0" .. entry.namespace] + continue unless result + rawPkg = @rawFeedData[Common.ScriptTypeSection[entry.scriptType]] + rawChannel = rawPkg and rawPkg[entry.namespace] + rawChannel = rawChannel and rawChannel.channels and rawChannel.channels[entry.channel] + continue unless rawChannel + hash, hashErr = FileOps.getHash entry.localFilePath + unless hash + result.errors[#result.errors + 1] = msgs.update.addFileHashFailed\format entry.name, tostring hashErr + continue + fileEntry = {name: entry.name, url: entry.url, sha1: hash\upper!} + fileEntry.type = entry.type if entry.type + rawChannel.files or= {} + insertFileEntry rawChannel.files, fileEntry + rawChannel.released = dkjson.null + result.changed = true + result.addedFiles or= {} + result.addedFiles[#result.addedFiles + 1] = {name: entry.name, type: entry.type} + + for result in *stats.packages + 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. A file the feed marks + ---for deletion (`delete: true`) is removed from distDir if present, rather than deployed. + ---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 deploy — a missing source, a copy error, or a deletion that couldn't be performed. + deployFiles: (distDir, filter, clobber = false) => + fileCount, errCount = 0, 0 + + for file, channel, pkg, _, scriptType in @walkFiles filter + if file.delete + 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 + if FileOps.exists dstPath, "file" + removed, _, remErr = FileOps.remove dstPath + if removed + @logger\hint msgs.bundle.deleted, dstPath + else + @logger\warn msgs.bundle.removeFailed, dstPath, tostring remErr + errCount += 1 + continue + 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 + + ---Finds files present on disk that a package's targeted channel doesn't list, by inverting + ---the channel's effective per-file-type local path templates. Only `localFileBasePaths` + ---entries whose sole unexpanded variable is `@{fileName}` are scanned; a file matching + ---several types is attributed to the one with the longest literal prefix. Loads the feed + ---in local mode if needed. + ---@param filter? ScriptTargetFilter Restricts which packages are scanned (default: all). + ---@param channelName? string Channel whose file list is diffed (default: each package's default channel). + ---@return {namespace: string, scriptType: ScriptType, channel: string, name: string, type?: string, url: string, localFilePath: string}[]? unlisted Feed-ready entries for the discovered files, sorted by namespace, type, and name (empty when everything on disk is listed; nil on a load error). + ---@return string? err Error message on failure. + findUnlistedFiles: (filter = ScriptTargetFilter!\includeAll!, channelName) => + loaded, err = @ensureLoaded @@ExpansionMode.Local + return nil, err unless loaded + + unlisted = {} + for pkg, scriptType, section in walkPackages @, filter + resolvedChannel, chanErr = @@__resolveChannel pkg.channels, channelName + unless resolvedChannel + @logger\warn msgs.findUnlistedFiles.channelError, pkg.namespace, chanErr + continue + state = @__channelTemplateState[section] + state = state and state[pkg.namespace] + state = state and state[resolvedChannel] + continue unless state and state.localFileBasePaths + + -- one scan spec per invertible file-type template + specs = {} + for fileType, template in pairs state.localFileBasePaths + prefix, suffix = template\match "^(.-)@{fileName}(.*)$" + if not prefix or prefix\find("@{", 1, true) or suffix\find("@{", 1, true) + @logger\warn msgs.findUnlistedFiles.notInvertible, pkg.namespace, resolvedChannel, template + continue + absPrefix, prefixErr = FileOps.validateFullPath prefix, false, @feedDir + unless absPrefix + @logger\warn msgs.findUnlistedFiles.badScanPath, pkg.namespace, resolvedChannel, prefix, tostring prefixErr + continue + specs[#specs + 1] = {:fileType, prefix: absPrefix, :suffix} + continue if #specs == 0 + + -- walk the directories containing the template prefixes; roots may nest, so the + -- `listed` set doubles as a guard against a file surfacing from two overlapping walks + scanRoots, seenRoots = {}, {} + for spec in *specs + root = spec.prefix\match "^(.*)[/\\]" + if root and not seenRoots[root] + seenRoots[root] = true + scanRoots[#scanRoots + 1] = root + + listed = {file.name .. "\0" .. (file.type or "script"), true for file in *(pkg.channels[resolvedChannel].files or {})} + for root in *scanRoots + for filePath in *(FileOps.listFilesRecursive(root) or {}) + best = nil + for spec in *specs + matches = #filePath > #spec.prefix + #spec.suffix and + filePath\sub(1, #spec.prefix) == spec.prefix and + (spec.suffix == "" or filePath\sub(-#spec.suffix) == spec.suffix) + best = spec if matches and (not best or #spec.prefix > #best.prefix) + continue unless best + name = filePath\sub(#best.prefix + 1, #filePath - #best.suffix)\gsub "[/\\]", "/" + continue if listed[name .. "\0" .. best.fileType] + listed[name .. "\0" .. best.fileType] = true + unlisted[#unlisted + 1] = { + namespace: pkg.namespace, :scriptType, channel: resolvedChannel, :name + type: best.fileType != "script" and best.fileType or nil + url: state.fileBaseUrls and state.fileBaseUrls[best.fileType] and "@{fileBaseUrl}" or "@{fileBaseUrl}@{fileName}" + localFilePath: filePath + } + + table.sort unlisted, (a, b) -> + return a.namespace < b.namespace if a.namespace != b.namespace + return (a.type or "") < (b.type or "") if (a.type or "") != (b.type or "") + return a.name < b.name + return unlisted + + ---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 (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 (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 + +Accessors.install UpdateFeed diff --git a/modules/l0/DependencyControl/UpdateTask.moon b/modules/l0/DependencyControl/UpdateTask.moon new file mode 100644 index 0000000..b104f0f --- /dev/null +++ b/modules/l0/DependencyControl/UpdateTask.moon @@ -0,0 +1,1058 @@ +lfs = require "lfs" +Downloader = require "l0.DependencyControl.Downloader" +UpdateFeed = require "l0.DependencyControl.UpdateFeed" +FeedTrust = require "l0.DependencyControl.FeedTrust" +fileOps = require "l0.DependencyControl.FileOps" +Common = require "l0.DependencyControl.Common" +Enum = require "l0.DependencyControl.Enum" +ModuleLoader = require "l0.DependencyControl.ModuleLoader" +SemanticVersion = require "l0.DependencyControl.SemanticVersion" +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + +---The "installation"/"update" term for a record's task. Common.terms.isInstall is keyed by +---true/false, while an installed record leaves `virtual` nil. +---@param record Record +---@return string term +getInstallTerm = (record) -> Common.terms.isInstall[record.virtual or false] + +-- How preferred a candidate package source is, in highest-to-lowest trust order. +---@alias UpdaterTrustBand +---| 1 # DeclaredDirect: the declared/own feed, trusted, offering the module by name +---| 2 # TrustedDirect: another trusted feed, offering the module by name +---| 3 # TrustedProvider: a trusted feed, offering a module that provides it +---| 4 # UntrustedDirect: an untrusted feed, offering the module by name +---| 5 # UntrustedProvider: an untrusted feed, offering a provider +TrustBand = Enum "UpdaterTrustBand", { + DeclaredDirect: 1 + TrustedDirect: 2 + TrustedProvider: 3 + UntrustedDirect: 4 + UntrustedProvider: 5 +} + +---A potential package source pooled during resolution to collect a feed's update record, where it came from +---and its trust status. +---@class CandidatePackageSource +---@field updateRecord ScriptUpdateRecord The feed's update record for this candidate, channel already selected. +---@field feedUrl string URL of the feed the candidate was found in. +---@field isDirect boolean True when the feed offers the required package by name; false when via a provider. +---@field trustBand UpdaterTrustBand The candidate's trust band. +---@field providesVersion? string For a provider, the alias version range it declares (nil = any version). + +---The outcome of resolving a package source: either a source to install or a status to return. +---@class UpdaterResolution +---@field installRequired boolean Whether the caller must perform an install/update. +---@field statusCode? UpdateStatus The status to return when no install is required (installRequired false). +---@field statusDetailMessage? any Detail accompanying statusCode (e.g. an untrusted feed URL or an error string). +---@field selectedSource? CandidatePackageSource The source to install; set when installRequired is true. +---@field stickiness? SourceChoiceStickiness The source-choice stickiness to persist; set when installRequired is true. +---@field maxVersion? number The highest candidate version found during resolution; set when installRequired is true. + +-- Why a given update is running. The values double as the rungs of the update-context ladder, +-- ordered by autonomy (see UpdateContextCeiling). +---@alias UpdateReason +---| "user-requested" # UserRequested: an explicit user/UI request (e.g. the Toolbox) +---| "dependency-resolution" # DependencyResolution: installing/updating a module as a dependency of another +---| "auto-update" # AutoUpdate: a background scheduled update check +UpdateReason = Enum "UpdateReason", { + UserRequested: "user-requested" + DependencyResolution: "dependency-resolution" + AutoUpdate: "auto-update" +} + +---A ceiling on the update-context ladder: names the most autonomous context for which a gated behavior — +---running at all (`updates.mode`) or prompting the user (the prompt thresholds) — still applies. +---Every value includes all less autonomous contexts; `off` includes none. +---@alias UpdateContextCeiling +---| "off" # Off: no context at all +---| "user-requested" # UserRequested: only actions the user starts themselves (e.g. via the Toolbox) +---| "dependency-resolution" # DependencyResolution: also installing/updating a module as a dependency +---| "auto-update" # AutoUpdate: also background scheduled update checks +ContextCeiling = Enum "UpdateContextCeiling", { + Off: "off" + UserRequested: UpdateReason.UserRequested + DependencyResolution: UpdateReason.DependencyResolution + AutoUpdate: UpdateReason.AutoUpdate +} + +-- Each context's rank on the ladder, for ceiling comparisons (`off` ranks below every context). +contextRank = { + [ContextCeiling.Off]: 0 + [ContextCeiling.UserRequested]: 1 + [ContextCeiling.DependencyResolution]: 2 + [ContextCeiling.AutoUpdate]: 3 +} + +-- The user's decision when asked to trust a candidate from an untrusted feed (a cancelled prompt is nil). +---@alias FeedTrustDecision +---| "once" # Once: use the untrusted feed for this install only +---| "always" # Always: add the feed to the user's trusted feeds +---| "never" # Never: add the feed to the user's blocked feeds +FeedTrustDecision = Enum "FeedTrustDecision", { + Once: "once" + Always: "always" + Never: "never" +} + +-- How sticky a remembered package-source choice is on subsequent resolutions of the same package. +---@alias SourceChoiceStickiness +---| "unset" # Unset: no preference recorded yet; resolve normally and prompt only if interactive +---| "once" # Once: prompt again whenever a choice remains, preselecting the remembered pick +---| "retain" # Retain: reuse the remembered pick whenever it's still eligible, without prompting +---| "pinned" # Pinned: always reuse the remembered pick; if it's gone, abort (required) or skip (optional) +---| "auto" # Auto: never prompt; always resolve via the ranking, refreshing the remembered pick for information +SourceChoiceStickiness = Enum "SourceChoiceStickiness", { + Unset: "unset" + Once: "once" + Retain: "retain" + Pinned: "pinned" + Auto: "auto" +} + +-- Where a remembered package source came from. +---@alias SourceFeedKind +---| "self-declared" # SelfDeclared: the feed declared in the record +---| "user-feed" # UserFeed: the per-package user override feed +---| "provider" # Provider: another module that provides the required one (URL taken from the provider's own source) +---| "other" # Other: a third-party trusted/extra feed; stores a literal feedUrl +SourceFeedKind = Enum "SourceFeedKind", { + SelfDeclared: "self-declared" + UserFeed: "user-feed" + Provider: "provider" + Other: "other" +} + +---A package's remembered source, persisted per-package as `currentSource`. +---@class SourceChoiceRecord +---@field feedSource SourceFeedKind Where the source came from. +---@field feedUrl? string The literal feed URL; only stored (and required) for the `other` feedSource. +---@field channel string The update channel the source was resolved on. +---@field provider? { namespace: string, version?: string } The provider that satisfied the requirement, when resolved indirectly. +---@field stickiness SourceChoiceStickiness How sticky the choice is. + +-- The outcome of an install/update operation. A non-negative value is a success/skip outcome; a +-- negative value is a failure whose message template is `updateError[value]`. +---@alias UpdateStatus +---| 0 # UpToDate: the installed version already satisfies the target +---| 1 # Installed: the install or update succeeded +---| 2 # AlreadyUpdated: another in-flight update already brought the package to the target version +---| 3 # SkippedOptional: an optional dependency couldn't be satisfied and was skipped +---| -1 # UpdaterDisabled: the updater is disabled in the config +---| -2 # InvalidNamespace: the record's namespace doesn't conform to the rules +---| -3 # Unmanaged: the record is virtual or unmanaged, so it isn't updated +---| -5 # AnotherUpdateRunning: another script or process holds the updater lock +---| -6 # NoSuitablePackage: no feed offered a package satisfying the requirement +---| -7 # NoInternet: no internet connection is available +---| -8 # InvalidVersion: the requested version string couldn't be parsed +---| -9 # ProtectedInstall: the entry point is in Aegisub's ?data automation directory +---| -10 # TaskAlreadyRunning: this update task is already running +---| -15 # RequirementsUnmet: the package's own required modules couldn't be satisfied +---| -16 # UntrustedFeed: the only suitable package is in an untrusted feed +---| -17 # PinnedUnavailable: the pinned package source is no longer available +---| -18 # UserAborted: the user aborted the update +---| -19 # BlockedFeed: the only suitable package is in a feed the user blocked +---| -30 # TempDirFailed: the temporary download directory couldn't be created +---| -33 # PathTraversal: a feed file tried to deploy outside its namespaced path +---| -35 # BadHash: a feed file carried a missing or malformed SHA-1 hash +---| -50 # MoveFailed: some downloaded files couldn't be moved into place +---| -55 # ModuleNotFound: the install succeeded but the module loader couldn't find the module +---| -56 # ModuleLoadFailed: the install succeeded but the module raised while loading +---| -57 # MissingVersionRecord: the installed module exposes no version record +---| -58 # RecordCreateFailed: creating an unmanaged record for the installed module failed +---| -140 # DownloadAddFailed: a file download couldn't be queued +---| -245 # DownloadFailed: one or more file downloads failed +UpdateStatus = Enum "UpdateStatus", { + UpToDate: 0 + Installed: 1 + AlreadyUpdated: 2 + SkippedOptional: 3 + UpdaterDisabled: -1 + InvalidNamespace: -2 + Unmanaged: -3 + AnotherUpdateRunning: -5 + NoSuitablePackage: -6 + NoInternet: -7 + InvalidVersion: -8 + ProtectedInstall: -9 + TaskAlreadyRunning: -10 + RequirementsUnmet: -15 + UntrustedFeed: -16 + PinnedUnavailable: -17 + UserAborted: -18 + BlockedFeed: -19 + TempDirFailed: -30 + PathTraversal: -33 + BadHash: -35 + MoveFailed: -50 + ModuleNotFound: -55 + ModuleLoadFailed: -56 + MissingVersionRecord: -57 + RecordCreateFailed: -58 + DownloadAddFailed: -140 + DownloadFailed: -245 +} + +msgs = { + updateError: { + [UpdateStatus.UpToDate]: "Couldn't complete the %s of %s '%s' because of a paradox: module not found but updater says up-to-date (%s)" + [UpdateStatus.UpdaterDisabled]: "Couldn't complete the %s of %s '%s' because the updater is disabled." + [UpdateStatus.InvalidNamespace]: "Skipping %s of %s '%s': namespace '%s' doesn't conform to rules." + [UpdateStatus.Unmanaged]: "Skipping %s of unmanaged %s '%s'." + [UpdateStatus.AnotherUpdateRunning]: "Skipped %s of %s '%s': another update initiated by %s is already running." + [UpdateStatus.NoSuitablePackage]: "The %s of %s '%s' failed because no suitable package could be found %s." + [UpdateStatus.NoInternet]: "Skipped %s of %s '%s': an internet connection is currently not available." + [UpdateStatus.InvalidVersion]: "Couldn't complete the %s of %s '%s' because the requested version is invalid: %s" + [UpdateStatus.ProtectedInstall]: "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." + [UpdateStatus.TaskAlreadyRunning]: "Skipped %s of %s '%s': the update task is already running." + [UpdateStatus.RequirementsUnmet]: "Couldn't complete the %s of %s '%s' because its requirements could not be satisfied:\n%s" + [UpdateStatus.UntrustedFeed]: "Couldn't complete the %s of %s '%s' because a suitable package was only found in an untrusted feed (%s). Add it to your trusted feeds to proceed." + [UpdateStatus.PinnedUnavailable]: "Couldn't complete the %s of %s '%s' because its pinned package source is no longer available. Update or clear the pin to proceed." + [UpdateStatus.UserAborted]: "Aborted the %s of %s '%s' at your request." + [UpdateStatus.BlockedFeed]: "Couldn't complete the %s of %s '%s' because you blocked the feed (%s) it would be installed from." + [UpdateStatus.TempDirFailed]: "Couldn't complete the %s of %s '%s': failed to create temporary download directory %s" + [UpdateStatus.PathTraversal]: "Aborted the %s of %s '%s' because it attempted to deploy a file (%s) outside of its namespaced path." + [UpdateStatus.BadHash]: "Aborted the %s of %s '%s' because the feed contained a missing or malformed SHA-1 hash for file %s." + [UpdateStatus.MoveFailed]: "Couldn't finish the %s of %s '%s' because some files couldn't be moved to their target location:\n" + [UpdateStatus.ModuleNotFound]: "The %s of %s '%s' succeeded, but the module couldn't be located by the module loader." + [UpdateStatus.ModuleLoadFailed]: "The %s of %s '%s' succeeded, but an error occurred while loading the module:\n%s" + [UpdateStatus.MissingVersionRecord]: "The %s of %s '%s' succeeded, but it's missing a version record." + [UpdateStatus.RecordCreateFailed]: "The %s of unmanaged %s '%s' succeeded, but an error occurred while creating a DependencyControl record: %s" + -- shared template for component-encoded statuses (value <= -100, e.g. DownloadAddFailed/DownloadFailed) + component: "Error (%d) in component %s during the %s of %s '%s':\n— %s" + -- fallback for a nil or unmapped status code, so error reporting can't itself fail + unknown: "Couldn't complete the %s of %s '%s' (unrecognized updater status: %s)." + } + updaterErrorComponent: {"DownloadManager (adding download)", "DownloadManager"} + checkFeed: { + fetchDenied: "Skipped feed %s: it's blocked, or untrusted while fetchUntrustedFeeds is 'never'. Trust the feed to update from it." + downloadFailed: "Failed to download feed: %s" + badChannel: "The specified update channel '%s' wasn't present in the feed." + invalidVersion: "The feed contains an invalid version record for %s '%s' (channel: %s): %s." + } + run: { + starting: "Starting %s of %s '%s'... " + fetching: "Trying to %sfetch missing %s '%s'..." + feedChecking: "Checking feed %s..." + upToDate: "The %s '%s' is up-to-date (v%s)." + alreadyUpdated: "%s v%s has already been installed." + noFeedAvailableExt: "(required: %s; installed: %s; available: %s)" + noPlatformAvailable: "for your platform (%s); a build is available only for %s" + skippedOptional: "Skipped %s of optional dependency '%s': %s" + optionalNoUpdate: "No suitable download could be found %s." + optionalUntrusted: "a suitable package was only found in an untrusted feed (%s)." + optionalBlocked: "you blocked the feed (%s) it would be installed from." + optionalPinnedUnavailable: "its pinned package source is no longer available." + optionalAborted: "aborted at your request." + providerAmbiguous: "Multiple modules provide '%s'; selected '%s' (candidates: %s)." + providerResolved: "Satisfying required module '%s' with provider %s '%s' (v%s)." + providerInstallFailed: "Found a provider for '%s' (%s) but it couldn't be installed: %s" + untrustedPrompt: "The %s of %s '%s' can only be satisfied from a feed DependencyControl doesn't trust:\n%s\n\nTrust this feed and proceed?" + choosePrompt: "More than one source can supply '%s'. Choose which one to install:" + choiceUnavailable: "Your remembered source for '%s' is no longer available. Please choose again:" + choiceUntrustedFlag: " [untrusted]" + } + + performUpdate: { + updateReqs: "Checking requirements..." + updateReady: "Update ready. Using temporary directory '%s'." + fileUnchanged: "Skipped unchanged file '%s'." + fileAddDownload: "Added Download %s ==> '%s'." + filesDownloading: "Downloading %d files..." + movingFiles: "Downloads complete. Now moving files to Aegisub automation directory '%s'..." + movedFile: "Moved '%s' ==> '%s'." + moveFileFailed: "Failed to move '%s' ==> '%s': %s" + updSuccess: "%s of %s '%s' (v%s) complete." + reloadNotice: "Please rescan your autoload directory for the changes to take effect." + unknownType: "Skipping file '%s': unknown type '%s'." + } + refreshRecord: { + 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." + } + __promptTrustFeed: { + trustOnce: "Trust this time" + trustAlways: "Always trust this feed" + trustNever: "Never (block this feed)" + } + promptSelectSource: { + once: "Just This Once" + retain: "Remember" + pinned: "Pin/Lock" + auto: "Auto-Pick" + abort: "Cancel" + } + dialogCommon: { + ok: "OK" + cancel: "Cancel" + } +} + +---Mutable execution state for one install/update operation. +---@class UpdateTask +class UpdateTask + ---@private + @__downloader = Downloader! + ---DependencyControl's own class, required lazily to break the circular dependency. + ---@private + @__DependencyControl = nil + + -- Defaults for the prompt-threshold `updates` settings this class owns, applied when the config key is unset. + ---@type UpdateContextCeiling + @defaultFeedTrustPromptThreshold = ContextCeiling.AutoUpdate + ---@type UpdateContextCeiling + @defaultPackageChoicePromptThreshold = ContextCeiling.UserRequested + + ---Reports whether an update context is allowed under a ceiling on the context ladder. + ---@param reason UpdateReason The context asking to act. + ---@param ceiling? UpdateContextCeiling The most autonomous context still allowed. + ---@return boolean within True when the context sits at or below the ceiling; false when the ceiling is `off`, unset, or unrecognized. + @isWithinContextCeiling = (reason, ceiling) -> + ceilingRank = contextRank[ceiling] + ceilingRank != nil and (contextRank[reason] or math.huge) <= ceilingRank + + ---Converts updater status/error codes into user-facing error messages. + ---@param code? UpdateStatus A nil or unmapped code yields a generic message naming the code. + ---@param name string + ---@param scriptType ScriptType A Common.ScriptType value. + ---@param isInstall boolean + ---@param detailMsg? string + ---@return string message The user-facing error text for the status code. + @getUpdaterErrorMsg = (code, name, scriptType, isInstall, detailMsg) -> + isInstall or= false -- terms.isInstall is keyed by true/false; tolerate a nil argument + detailMsg or= "" -- a template's trailing %s must never format a nil detail + if code and code <= -100 + -- a component-encoded status packs its component id as floor(-code / 100) + return msgs.updateError.component\format -code, msgs.updaterErrorComponent[math.floor(-code/100)], + Common.terms.isInstall[isInstall], Common.terms.scriptType.singular[scriptType], name, detailMsg + template = msgs.updateError[code] + unless template + return msgs.updateError.unknown\format Common.terms.isInstall[isInstall], + Common.terms.scriptType.singular[scriptType], name, tostring code + return template\format Common.terms.isInstall[isInstall], + Common.terms.scriptType.singular[scriptType], + name, detailMsg + + ---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 optional? boolean Treat this as an optional dependency. + ---@param channel? string Update channel to use. + ---@param reason? UpdateReason Why this task runs; a prompt is allowed only when this reason is permitted by that prompt kind's configured threshold. + ---@param updater Updater + new: (@record, targetVersionNumber = 0, @addFeeds, @optional, @channel, @reason, @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 = targetVersionNumber + + ---Loads a candidate feed, downloading it if necessary. + ---@param feedUrl string + ---@return UpdateFeed? feed The loaded feed, or nil on download failure. + ---@return string? err Error message on failure. + ---@private + __loadFeed: (feedUrl) => + -- Refuse a blocked feed outright, and an untrusted one while fetchUntrustedFeeds is 'never'; the + -- surfaced reason keeps a skipped update from being silent. Untrusted feeds under always/prompt are + -- still fetched — the install-trust prompt (feedTrustPromptThreshold) gates acting on them. + feedTrust = @updater.feedTrust + return nil, msgs.checkFeed.fetchDenied\format feedUrl if feedTrust and feedTrust\getFetchDecision(feedUrl) == FeedTrust.FetchDecision.Deny + + feed = @updater.feedLoader\load feedUrl, {autoLoad: false} + -- ensureLoaded reuses an in-memory/on-disk copy where valid, otherwise downloads + data, err = feed\ensureLoaded! + return nil, msgs.checkFeed.downloadFailed\format err unless data + return feed + + ---Looks up this task's module in an already-loaded feed (matched by namespace), returning its + ---update record on the task's channel along with the parsed release version. + ---@param feed UpdateFeed A feed loaded via __loadFeed. + ---@return ScriptUpdateRecord|nil record The module's update record, or nil if absent/unusable. + ---@return string? err Error message worth reporting (nil when the module simply isn't in this feed). + ---@return number? version The candidate's parsed version number. + ---@private + checkFeed: (feed) => + updateRecord, err = feed\getScript @record.namespace, @record.scriptType, @record.config, false + return nil, err unless updateRecord -- err is nil for "not in this feed", set for a real error + + success, currentChannel = updateRecord\setChannel @channel + return nil, msgs.checkFeed.badChannel\format currentChannel unless success + + version = SemanticVersion\toPacked updateRecord.version + unless version + return nil, msgs.checkFeed.invalidVersion\format Common.terms.scriptType.singular[@record.scriptType], + @record.name, currentChannel, tostring updateRecord.version + return updateRecord, nil, version + + ---Resolves the feed URL a persisted source record maps to, given the owning package's feed fields. + ---@param source SourceChoiceRecord The persisted source record. + ---@param selfFeed? string The package's declared feed (used for a self-declared source). + ---@param userFeed? string The package's per-package override feed (used for a user-feed source). + ---@param modulesSection? table<string, table> The modules config section (used to resolve a provider source). + ---@return string? url The resolved feed URL, or nil if it can't be determined. + @resolveSourceUrl = (source, selfFeed, userFeed, modulesSection) -> + switch source.feedSource + when SourceFeedKind.SelfDeclared then selfFeed + when SourceFeedKind.UserFeed then userFeed + when SourceFeedKind.Other then source.feedUrl + when SourceFeedKind.Provider + return nil unless source.provider and source.provider.namespace + provider = modulesSection and modulesSection[source.provider.namespace] + provider and provider.feed + + ---Resolves the feed URL a remembered source maps to. Every kind but `Other` derives its URL from + ---current state, so a remembered choice survives a feed-URL migration. + ---@param previousSource SourceChoiceRecord A persisted `currentSource` table. + ---@return string? feedUrl The derived feed URL, or nil if it can't be determined. + ---@private + __resolveRememberedFeedUrl: (previousSource) => + view = @updater.config\getSectionHandler Common.ScriptTypeSection[Common.ScriptType.Module] + @@.resolveSourceUrl previousSource, @record.feed, @record.config.c.userFeed, view and view.c + + ---Finds the candidate corresponding to a remembered source that is still eligible to satisfy this task. + ---@param candidates CandidatePackageSource[] Pooled candidates. + ---@param previousSource SourceChoiceRecord A persisted `currentSource` table. + ---@return CandidatePackageSource? candidate The matching, eligible candidate, or nil if none matches or it's no longer eligible. + ---@private + __matchRememberedCandidate: (candidates, previousSource) => + wantUrl = @__resolveRememberedFeedUrl previousSource + return nil unless wantUrl + viaProvider = previousSource.feedSource == SourceFeedKind.Provider + for candidate in *candidates + continue unless candidate.feedUrl == wantUrl + if viaProvider + continue if candidate.isDirect + continue unless previousSource.provider and candidate.updateRecord.namespace == previousSource.provider.namespace + else + continue unless candidate.isDirect + return candidate if @__getCandidateRankVersion candidate + return nil + + ---Classifies which kind of source a provided candidate came from. + ---@param candidate CandidatePackageSource The candidate to classify. + ---@return SourceFeedKind kind The candidate's source kind (self-declared, user-feed, provider, or other). + ---@private + __feedSourceOf: (candidate) => + return SourceFeedKind.Provider unless candidate.isDirect + return SourceFeedKind.SelfDeclared if candidate.feedUrl == @record.feed + return SourceFeedKind.UserFeed if candidate.feedUrl == @record.config.c.userFeed + return SourceFeedKind.Other + + ---Records which source satisfied this task and how sticky the choice is, in the per-package + ---`currentSource` config, so later resolutions can honor it. Writes only when something changed. + ---@param selectedCandidate CandidatePackageSource The chosen candidate. + ---@param stickiness? SourceChoiceStickiness The stickiness to record (defaults to the existing one, else `unset`). + ---@private + __persistSource: (selectedCandidate, stickiness) => + return unless @record.config + existing = @record.config.c.currentSource + feedSource = @__feedSourceOf selectedCandidate + currentSource = { + :feedSource + channel: selectedCandidate.updateRecord.activeChannel or @channel + stickiness: stickiness or (existing and existing.stickiness) or SourceChoiceStickiness.Unset + } + currentSource.feedUrl = selectedCandidate.feedUrl if feedSource == SourceFeedKind.Other + unless selectedCandidate.isDirect + currentSource.provider = {namespace: selectedCandidate.updateRecord.namespace, version: selectedCandidate.providesVersion} + + unchanged = existing and existing.feedSource == currentSource.feedSource and + existing.channel == currentSource.channel and existing.stickiness == currentSource.stickiness and + existing.feedUrl == currentSource.feedUrl and + (existing.provider and existing.provider.namespace) == (currentSource.provider and currentSource.provider.namespace) and + (existing.provider and existing.provider.version) == (currentSource.provider and currentSource.provider.version) + return if unchanged + + @record.config.c.currentSource = currentSource + @record.config\save! + + ---The version this candidate is ranked by, or nil when it can't satisfy the task. A direct candidate ranks by + ---its release version, a provider by the highest version its declared alias range covers (any version if none). + ---@param candidate CandidatePackageSource + ---@return number? rankVersion The ranking version, or nil if the candidate is ineligible. + ---@private + __getCandidateRankVersion: (candidate) => + local rankVersion + if candidate.isDirect + versionNumber = SemanticVersion\toPacked candidate.updateRecord.version + return nil unless versionNumber and SemanticVersion\check versionNumber, @targetVersion + rankVersion = versionNumber + else + -- a provider is matched and ranked by its declared alias range, defaulting to any version + range = candidate.providesVersion or "*" + return nil unless SemanticVersion\rangesIntersect range, ">=#{SemanticVersion\toString @targetVersion}" + rankVersion = SemanticVersion\getRangeMaxVersion range + return nil unless rankVersion + return nil unless candidate.updateRecord\checkPlatform! + return nil unless candidate.updateRecord.files and #candidate.updateRecord.files > 0 + rankVersion + + ---Selects the best candidate to satisfy this task's requirement from a pooled set, ranking by trust band, then + ---version, then a deterministic tie-break. + ---@param candidates CandidatePackageSource[] Pooled candidates, channel already selected on each record. + ---@return CandidatePackageSource? selected The chosen candidate, or nil when none is eligible. + ---@return CandidatePackageSource[]? tied The selected candidate plus any others tied with it on band and version (for an interactive chooser); nil when none is eligible. + ---@return CandidatePackageSource[]? eligible All eligible candidates, sorted best-first (for the offer-all-sources chooser); nil when none is eligible. + ---@private + __selectCandidate: (candidates) => + eligibleCandidates = {} + for candidate in *candidates + rankVersion = @__getCandidateRankVersion candidate + eligibleCandidates[#eligibleCandidates + 1] = {:candidate, :rankVersion} if rankVersion + return nil if #eligibleCandidates == 0 + + declaredFeed = @record.feed + table.sort eligibleCandidates, (a, b) -> + return a.candidate.trustBand < b.candidate.trustBand if a.candidate.trustBand != b.candidate.trustBand + return a.rankVersion > b.rankVersion if a.rankVersion != b.rankVersion + aDeclared, bDeclared = a.candidate.feedUrl == declaredFeed, b.candidate.feedUrl == declaredFeed + return aDeclared if aDeclared != bDeclared + return a.candidate.updateRecord.namespace < b.candidate.updateRecord.namespace + + winner, topVersion = eligibleCandidates[1].candidate, eligibleCandidates[1].rankVersion + tied = [e.candidate for e in *eligibleCandidates when e.candidate.trustBand == winner.trustBand and e.rankVersion == topVersion] + if #tied > 1 + @logger\log msgs.run.providerAmbiguous, @record.namespace, winner.updateRecord.namespace, + table.concat [c.updateRecord.namespace for c in *tied], ", " + return winner, tied, [e.candidate for e in *eligibleCandidates] + + ---Installs a module that `provides` this task's required module, satisfying the requirement + ---indirectly with that provider. + ---@param provider ScriptUpdateRecord The selected provider's feed record. + ---@param feedUrl string The feed the provider was found in, used as its primary feed. + ---@return any ref The loaded provider module reference, or nil on failure. + ---@return UpdateStatus? code Status of the provider's update run. Always present when the ref is nil. + ---@return string? detail Error detail on failure. + ---@private + __installProvider: (provider, feedUrl) => + @@__DependencyControl or= require "l0.DependencyControl" + providerRecord = @@.__DependencyControl { + moduleName: provider.namespace, name: provider.name or provider.namespace, + version: -1, virtual: true, feed: feedUrl, url: provider.url + } + addFeeds = [feed for feed in *@addFeeds] + addFeeds[#addFeeds + 1] = feedUrl + @updater\require providerRecord, @targetVersion, addFeeds, @optional, nil, @reason + + ---Reports whether this task may prompt the user for a given kind of prompt, + ---e.g. to approve an untrusted feed or choose among multiple eligible candidates. + ---@param threshold? UpdateContextCeiling The configured ceiling for this prompt kind. + ---@return boolean allowed True when a prompt of this kind is permitted for this task's reason. + ---@private + __shouldPrompt: (threshold) => + return false unless @reason + @@.isWithinContextCeiling @reason, threshold + + ---Asks the user whether to proceed with a candidate from an untrusted feed. Depending on the user's choice, + ---the feed may be added to the trusted or blocked lists. + ---@param selectedCandidate CandidatePackageSource A candidate source from an untrusted feed. + ---@return FeedTrustDecision? decision The user's decision, or nil if they cancelled. + ---@private + __promptTrustFeed: (selectedCandidate) => + msg = msgs.run.untrustedPrompt\format getInstallTerm(@record), + Common.terms.scriptType.singular[@record.scriptType], + @record.name, selectedCandidate.feedUrl + dlg = {{class: "label", label: msg, x: 0, y: 0, width: 1, height: 1}} + buttons = {msgs.dialogCommon.cancel, msgs.__promptTrustFeed.trustOnce, msgs.__promptTrustFeed.trustAlways, + msgs.__promptTrustFeed.trustNever} + + btn = aegisub.dialog.display dlg, buttons, {cancel: msgs.dialogCommon.cancel} + return nil if not btn or btn == msgs.dialogCommon.cancel + + switch btn + when msgs.__promptTrustFeed.trustAlways + @updater.feedTrust\trust selectedCandidate.feedUrl + return FeedTrustDecision.Always + when msgs.__promptTrustFeed.trustNever + @updater.feedTrust\block selectedCandidate.feedUrl + return FeedTrustDecision.Never + FeedTrustDecision.Once + + ---Lets the user pick a package source among the eligible candidates and how sticky that pick should be. + ---Untrusted candidates are flagged in the list. Choosing "Auto-Pick" keeps the algorithm's pre-selected candidate. + ---@param candidates CandidatePackageSource[] The candidates to choose from. + ---@param selectedCandidate? CandidatePackageSource The candidate to pre-select (defaults to the first). + ---@param noLongerAvailable? boolean Show the "remembered source unavailable" prompt instead of the default one. + ---@return CandidatePackageSource? chosen The picked candidate, or nil if the user aborted. + ---@return SourceChoiceStickiness? stickiness How sticky the pick should be, or nil if the user aborted. + ---@private + __promptSelectPackageSource: (candidates, selectedCandidate = candidates[1], noLongerAvailable) => + labelFor = (candidate) -> + label = "#{candidate.updateRecord.name or candidate.updateRecord.namespace} (#{candidate.feedUrl})" + label ..= msgs.run.choiceUntrustedFlag if candidate.trustBand and candidate.trustBand >= TrustBand.UntrustedDirect + label + candidatesByLabel = {labelFor(candidate), candidate for candidate in *candidates} + + prompt = noLongerAvailable and msgs.run.choiceUnavailable or msgs.run.choosePrompt + dlg = { + {class: "label", label: prompt\format(@record.namespace), x: 0, y: 0, width: 2, height: 1} + {class: "dropdown", name: "choice", items: [labelFor c for c in *candidates], value: labelFor(selectedCandidate), + x: 0, y: 1, width: 2, height: 1} + } + buttons = {msgs.promptSelectSource.once, msgs.promptSelectSource.retain, msgs.promptSelectSource.pinned, + msgs.promptSelectSource.auto, msgs.promptSelectSource.abort} + btn, res = aegisub.dialog.display dlg, buttons, {cancel: msgs.promptSelectSource.abort} + + switch btn + when msgs.promptSelectSource.auto then return selectedCandidate, SourceChoiceStickiness.Auto + when msgs.promptSelectSource.once then return (candidatesByLabel[res.choice] or selectedCandidate), SourceChoiceStickiness.Once + when msgs.promptSelectSource.retain then return (candidatesByLabel[res.choice] or selectedCandidate), SourceChoiceStickiness.Retain + when msgs.promptSelectSource.pinned then return (candidatesByLabel[res.choice] or selectedCandidate), SourceChoiceStickiness.Pinned + return nil, nil + + ---Runs the full update/install flow for this task. + ---Acquires the global updater lock but does not release it — the caller (a Record.requireModules / + ---macro-hook / Toolbox entry point) must call `updater\releaseLock` once its whole operation is done, + ---or the lock is held until its lease lapses (orphanTimeout), blocking other scripts' updates. + ---@param waitLock? boolean Wait for a concurrent update to finish instead of bailing. + ---@return UpdateStatus statusCode + ---@return any detail + run: (waitLock) => + with @record do @logger\log msgs.run.starting, getInstallTerm(@record), + Common.terms.scriptType.singular[.scriptType], .name + + -- don't perform update of a script when another one is already running for the same script + return @__logUpdateError UpdateStatus.TaskAlreadyRunning 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 UpdateStatus.ProtectedInstall, entryPath + + -- check if the script was already updated + if @updated and @record\checkVersion @targetVersion + @logger\log msgs.run.alreadyUpdated, @record.name, SemanticVersion\toString @record.version + return UpdateStatus.AlreadyUpdated + + -- check internet connection + return @__logUpdateError UpdateStatus.NoInternet unless @@__downloader\isInternetConnected! + + -- get a lock on the updater + success, otherHost = @updater\acquireLock waitLock + return @__logUpdateError UpdateStatus.AnotherUpdateRunning, otherHost unless success + + resolution = @__resolve! + return resolution.statusCode, resolution.statusDetailMessage unless resolution.installRequired + selectedSource, stickiness, maxVersion = resolution.selectedSource, resolution.stickiness, resolution.maxVersion + + -- remember which source satisfied this package and how sticky the choice is, for next time + @__persistSource selectedSource, stickiness + + -- an installed module already satisfies the chosen (trusted) version + if selectedSource.isDirect and not @record.virtual and @record\checkVersion selectedSource.updateRecord.version + @logger\log msgs.run.upToDate, Common.terms.scriptType.singular[@record.scriptType], + @record.name, SemanticVersion\toString @record.version + return UpdateStatus.UpToDate + + wasVirtual = @record.virtual + if selectedSource.isDirect + code, res = @performUpdate selectedSource.updateRecord + return @__logUpdateError code, res, wasVirtual + + -- for an indirect source, install the chosen provider in place of the required module + ref, code, extErr = @__installProvider selectedSource.updateRecord, selectedSource.feedUrl + unless ref + @logger\trace msgs.run.providerInstallFailed, @record.namespace, selectedSource.updateRecord.namespace, tostring extErr + code, detail = @__reportNoSuitablePackage maxVersion + return code, detail + @ref, @updated = ref, true + @logger\log msgs.run.providerResolved, @record.namespace, Common.terms.scriptType.singular[Common.ScriptType.Module], + selectedSource.updateRecord.name or selectedSource.updateRecord.namespace, selectedSource.updateRecord.version + return UpdateStatus.Installed, selectedSource.updateRecord.version + + ---Logs the error message for a negative status code and returns the code and detail unchanged. + ---Non-negative (success/skip) codes are returned without logging. + ---@param statusCode UpdateStatus The updater status code. + ---@param statusDetailMessage? string a message with further explanation of the outcome, if any. + ---@param virtual? boolean Whether this is a fresh install (default: the record's current virtual flag). + ---@return UpdateStatus statusCode the same code passed in. + ---@return string? statusDetailMessage the same status detail message passed in, if any. + ---@private + __logUpdateError: (statusCode, statusDetailMessage, virtual = @record.virtual) => + if statusCode < 0 + @logger\log UpdateTask.getUpdaterErrorMsg statusCode, @record.name, @record.scriptType, virtual, statusDetailMessage + return statusCode, statusDetailMessage + + ---The platforms a version-satisfying build is offered for when none covers the current platform, so a + ---"no suitable package" failure can be attributed to a platform mismatch. + ---@param candidates CandidatePackageSource[] The candidates pooled during resolution. + ---@return string[] platforms Offered platforms, sorted and de-duplicated; empty when the shortfall isn't a platform mismatch. + ---@private + __getOfferedBuildPlatforms: (candidates) => + offered = {} + for candidate in *candidates + continue unless candidate.isDirect + versionNumber = SemanticVersion\toPacked candidate.updateRecord.version + continue unless versionNumber and SemanticVersion\check versionNumber, @targetVersion + continue if candidate.updateRecord\checkPlatform! + offered[platform] = true for platform in *(candidate.updateRecord.platforms or {}) + platforms = [platform for platform in pairs offered] + table.sort platforms + return platforms + + ---Logs and returns this task's "no suitable package" status — a skip if optional, else a failure. + ---@param maxVersion number The highest candidate version seen during resolution (0 when none was found). + ---@param offeredPlatforms? string[] The platforms a version-satisfying build is offered for; when non-empty, the failure is reported as a platform mismatch rather than a version shortfall. + ---@return UpdateStatus statusCode A negative failure code for a required dependency, or the skip code (3) for an optional one. + ---@return string? statusDetailMessage The availability summary for a failure; nil for an optional skip. + ---@private + __reportNoSuitablePackage: (maxVersion, offeredPlatforms) => + detail = if offeredPlatforms and #offeredPlatforms > 0 + msgs.run.noPlatformAvailable\format Common.platform, table.concat offeredPlatforms, ", " + else + msgs.run.noFeedAvailableExt\format @targetVersion == 0 and "any" or SemanticVersion\toString(@targetVersion), + @record.virtual and "no" or SemanticVersion\toString(@record.version), + maxVersion < 1 and "none" or SemanticVersion\toString maxVersion + if @optional + @logger\log msgs.run.skippedOptional, getInstallTerm(@record), @record.name, + msgs.run.optionalNoUpdate\format detail + return UpdateStatus.SkippedOptional + return @__logUpdateError UpdateStatus.NoSuitablePackage, detail + + ---Resolves which package source should satisfy this task, without installing anything. May fetch feeds + ---and prompt the user (to choose a package source or to approve an untrusted feed). The updater lock + ---must already be held. + ---@return UpdaterResolution resolution The source to install, or a status code to return when no install is needed. + ---@private + __resolve: => + withoutInstall = (statusCode, statusDetailMessage) -> {installRequired: false, :statusCode, :statusDetailMessage} + + -- Candidates are ranked by trust band. Feeds are fetched lazily per band, so we only reach for + -- less-trusted feeds when no closer source can satisfy. + config, userFeed, declaredFeed = @updater.config.c.updates, @record.config.c.userFeed, @record.feed + feedTrust = @updater.feedTrust + isBlocked = (url) -> feedTrust\isBlocked url + -- a user override feed counts as trusted for this resolution (unless block-listed), without polluting the shared set + userFeedTrusted = userFeed and not isBlocked userFeed + isTrusted = (url) -> feedTrust\isTrusted(url) or (userFeedTrusted and url == userFeed) + + -- the remembered package source for this package, and how sticky the user's last choice was + remembered = @record.config.c.currentSource + stickiness = remembered and remembered.stickiness or SourceChoiceStickiness.Unset + -- a remembered provider stays pinned to its band so a version bump updates it in place instead of switching providers + stickyProvider = remembered and remembered.provider and remembered.provider.namespace + + bandOf = (feedUrl, direct) -> + if isTrusted feedUrl + return direct and (feedUrl == declaredFeed and TrustBand.DeclaredDirect or TrustBand.TrustedDirect) or TrustBand.TrustedProvider + return direct and TrustBand.UntrustedDirect or TrustBand.UntrustedProvider + + maxVer, candidates = 0, {} + + -- Gather candidates from a list of feed URLs, skipping any that are blocked or already tried. + gather = (feedUrls) -> + for feedUrl in *(feedUrls or {}) + continue if not feedUrl or @triedFeeds[feedUrl] or isBlocked feedUrl + @triedFeeds[feedUrl] = true + @updater\renewLock! + @logger\trace msgs.run.feedChecking, feedUrl + feed, errMsg = @__loadFeed feedUrl + unless feed + @logger\log errMsg + continue + rec, errMsg, version = @checkFeed feed + + if rec + maxVer = math.max maxVer, version + candidates[#candidates + 1] = {updateRecord: rec, feedUrl: feedUrl, isDirect: true, trustBand: bandOf(feedUrl, true)} + elseif errMsg + @logger\log errMsg + if @record.virtual + for provider in *feed\getProviders @record.namespace + -- the version range this provider declares for the required alias, if any + providesVersions = [e.version for e in *(provider.provides or {}) when type(e) == "table" and e.name == @record.namespace] + -- a trusted candidate from the sticky (remembered/installed) provider stays pinned (declared-direct band) + trustBand = (provider.namespace == stickyProvider and isTrusted feedUrl) and TrustBand.DeclaredDirect or bandOf(feedUrl, false) + candidates[#candidates + 1] = {updateRecord: provider, feedUrl: feedUrl, isDirect: false, :trustBand, providesVersion: providesVersions[1]} + + @logger.indent += 1 + + local selected, tied, eligible + -- A hard pin or soft-remember reuses the remembered source directly: load its feed first (it may + -- sit in a lower or untrusted tier the cascade would never reach) and reuse it if still eligible. + -- An exclusive userFeed still constrains it, so a remembered source outside userFeed counts as gone. + reuse = nil + if stickiness == SourceChoiceStickiness.Pinned or stickiness == SourceChoiceStickiness.Retain + rememberedUrl = @__resolveRememberedFeedUrl remembered + if rememberedUrl and (not userFeed or rememberedUrl == userFeed) + gather {rememberedUrl} + reuse = @__matchRememberedCandidate candidates, remembered + + if reuse + selected = reuse + elseif stickiness == SourceChoiceStickiness.Pinned + -- pinned, but the remembered source is gone: don't silently switch to another one + selected = nil + elseif userFeed + gather {userFeed} + selected, tied, eligible = @__selectCandidate candidates + else + -- tier 1: the feed declared by / advertised in the record + gather {declaredFeed} + selected, tied, eligible = @__selectCandidate candidates + + unless selected and selected.trustBand == TrustBand.DeclaredDirect + -- tier 2: the trusted discovery feeds — the user's extra feeds, trusted add-feeds, and the + -- official set. (extraFeeds lives in the `feeds` section, not `updates`.) + gather @updater.config.c.feeds.extraFeeds + gather [url for url in *@addFeeds when isTrusted url] + gather [url for url in pairs(feedTrust\getOfficialTrustedFeeds!)] unless @optional -- don't trigger a registry-wide crawl for a nice-to-have + selected, tied, eligible = @__selectCandidate candidates + + unless selected and selected.trustBand <= TrustBand.TrustedProvider + -- tier 3: untrusted feeds + gather [url for url in *@addFeeds when not isTrusted url] + selected, tied, eligible = @__selectCandidate candidates + @logger.indent -= 1 + + abortResolution = -> + if @optional + @logger\log msgs.run.skippedOptional, getInstallTerm(@record), @record.name, + msgs.run.optionalAborted + return UpdateStatus.SkippedOptional + return @__logUpdateError UpdateStatus.UserAborted + + -- a hard pin whose remembered source vanished aborts (required) or skips (optional) + if stickiness == SourceChoiceStickiness.Pinned and not reuse + if @optional + @logger\log msgs.run.skippedOptional, getInstallTerm(@record), @record.name, + msgs.run.optionalPinnedUnavailable + return withoutInstall UpdateStatus.SkippedOptional + code, detail = @__logUpdateError UpdateStatus.PinnedUnavailable + return withoutInstall code, detail + + unless selected + if maxVer > 0 and not @record.virtual and @targetVersion <= @record.version + -- dependency is already up-to-date, so no matter we don't have a candidate to install + @logger\log msgs.run.upToDate, Common.terms.scriptType.singular[@record.scriptType], + @record.name, SemanticVersion\toString @record.version + return withoutInstall UpdateStatus.UpToDate + + code, detail = @__reportNoSuitablePackage maxVer, @__getOfferedBuildPlatforms candidates + return withoutInstall code, detail + + -- consult the remembered source choice to decide whether to let the user pick a package source. + -- `auto` never asks and the reuse path already settled the pick, so neither prompts here. + unless reuse or stickiness == SourceChoiceStickiness.Auto + -- present every eligible candidate when configured to, otherwise only an exact band/version tie + choices = config.offerAllSources and eligible or tied + allowPrompt = @__shouldPrompt(config.packageChoicePromptThreshold or @@defaultPackageChoicePromptThreshold) + remPick = remembered and @__matchRememberedCandidate candidates, remembered + + if stickiness == SourceChoiceStickiness.Retain + -- the soft-remembered pick is gone: re-ask in interactive mode, downgrade to `once` otherwise + if allowPrompt + pick, pickType = @__promptSelectPackageSource choices, selected, true + unless pick + code, detail = abortResolution! + return withoutInstall code, detail + selected, stickiness = pick, pickType + else + stickiness = SourceChoiceStickiness.Once + elseif choices and #choices > 1 and allowPrompt + pick, pickType = @__promptSelectPackageSource choices, (remPick or selected) + unless pick + code, detail = abortResolution! + return withoutInstall code, detail + selected, stickiness = pick, pickType + + -- the chosen candidate is from an untrusted feed: install it only if the user is asked and approves + if selected.trustBand >= TrustBand.UntrustedDirect + trustDecision = @__shouldPrompt(@updater.config.c.updates.feedTrustPromptThreshold or @@defaultFeedTrustPromptThreshold) and @__promptTrustFeed selected + unless trustDecision == FeedTrustDecision.Once or trustDecision == FeedTrustDecision.Always + userBlockedFeed = trustDecision == FeedTrustDecision.Never + if @optional + reason = (userBlockedFeed and msgs.run.optionalBlocked or msgs.run.optionalUntrusted)\format selected.feedUrl + @logger\log msgs.run.skippedOptional, getInstallTerm(@record), @record.name, reason + return withoutInstall UpdateStatus.SkippedOptional + code, detail = @__logUpdateError (userBlockedFeed and UpdateStatus.BlockedFeed or UpdateStatus.UntrustedFeed), selected.feedUrl + return withoutInstall code, detail + + return {installRequired: true, selectedSource: selected, :stickiness, maxVersion: maxVer} + + ---Downloads and installs files for a selected update entry. + ---@param update ScriptUpdateRecord + ---@return UpdateStatus statusCode + ---@return table|string|nil detail + ---@private + performUpdate: (update) => + finish = (...) -> + @running = false + if @record.virtual or @record.recordType == Common.RecordType.Unmanaged + ModuleLoader.removeDummyRef @record + return ... + + @running = true + + -- set a dummy ref (which hasn't yet been set for virtual and unmanaged modules) + -- and record version to allow resolving circular dependencies + if @record.virtual or @record.updateRecordType == Common.RecordType.Unmanaged + ModuleLoader.createDummyRef @record + @record\setVersion update.version + + -- try to load required modules first to see if all dependencies are satisfied + -- this may trigger more updates + reqs = update.requiredModules + if reqs and #reqs > 0 + @logger\log msgs.performUpdate.updateReqs + @logger.indent += 1 + success, err = ModuleLoader.loadModules @record, reqs, {@record.feed} + @logger.indent -= 1 + unless success + @logger.indent += 1 + @logger\log err + @logger.indent -= 1 + return finish UpdateStatus.RequirementsUnmet, err + + -- since circular dependencies are possible, our task may have completed in the meantime + -- so check again if we still need to update + return finish UpdateStatus.AlreadyUpdated if @updated and @record\checkVersion update.version + + + -- download updated scripts to temp directory + -- check hashes before download, only update changed files + + tmpDir = fileOps.getTempDir! + res, dir = fileOps.mkdir tmpDir + + return finish UpdateStatus.TempDirFailed, "#{tmpDir} (#{dir})" if res == nil + + @logger\log msgs.performUpdate.updateReady, tmpDir + + scriptSubDir = @record.namespace + scriptSubDir = scriptSubDir\gsub "%.","/" if @record.scriptType == Common.ScriptType.Module + + @@__downloader.blockPrivateHosts = @updater.config.c.updates.blockPrivateHosts + @@__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", "test" + return finish UpdateStatus.PathTraversal, 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 + continue + continue if file.delete + + unless type(file.sha1)=="string" and #file.sha1 == 40 and tonumber(file.sha1, 16) + return finish UpdateStatus.BadHash, "#{prettyName} (#{tostring(file.sha1)\lower!})" + + if fileOps.verifyHash file.fullName, file.sha1 + @logger\trace msgs.performUpdate.fileUnchanged, prettyName + continue + + dl, err = @@__downloader\addDownload file.url, tmpName, file.sha1 + return finish UpdateStatus.DownloadAddFailed, err unless dl + dl.targetFile = file.fullName + @logger\trace msgs.performUpdate.fileAddDownload, file.url, prettyName + + @@__downloader\await (_, progress) -> + @updater\renewLock! + @logger\progress progress, msgs.performUpdate.filesDownloading, #@@__downloader.downloads + @logger\progress! + + 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 UpdateStatus.DownloadFailed, err + + + -- move files to their destination directory and clean up + + @logger\log msgs.performUpdate.movingFiles, @record.automationDir + moveErrors = {} + @logger.indent += 1 + 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 + if res + @logger\trace msgs.performUpdate.movedFile, dl.outfile, dl.targetFile + else + @logger\log msgs.performUpdate.moveFileFailed, dl.outfile, dl.targetFile, err + moveErrors[#moveErrors+1] = err + @logger.indent -= 1 + + if #moveErrors>0 + return finish UpdateStatus.MoveFailed, @logger\format moveErrors, 1 + else fileOps.rmdir tmpDir -- recurses by default: the temp dir still holds the per-type subdirectories + os.remove file.fullName for file in *update.files when file.delete and not file.unknown + + -- Nuke old module refs and reload + oldVer, wasVirtual = @record.version, @record.virtual + + -- Update complete, refresh module information/configuration + if @record.scriptType == Common.ScriptType.Module + ref = ModuleLoader.loadModule @record, @record, false, true + unless ref + if @record._error + return finish UpdateStatus.ModuleLoadFailed, @logger\format @record._error, 1 + else return finish UpdateStatus.ModuleNotFound + + -- get a fresh version record + if type(ref.version) == "table" and ref.version.__class.__name == @@__DependencyControl.__name + @record = ref.version + else + -- look for any compatible non-DepCtrl version records and create an unmanaged record + return finish UpdateStatus.MissingVersionRecord unless ref.version + success, rec = pcall @@__DependencyControl, { moduleName: @record.moduleName, version: ref.version, + recordType: Common.RecordType.Unmanaged, name: @record.name } + return finish UpdateStatus.RecordCreateFailed, rec unless success + @record = rec + @ref = ref + + else with @record + .name = @record.name + .virtual = false + .version = SemanticVersion\toPacked update.version + @record\writeConfig! + + @updated = true + @logger\log msgs.performUpdate.updSuccess, Common.terms.capitalize(Common.terms.isInstall[wasVirtual or false]), + Common.terms.scriptType.singular[@record.scriptType], + @record.name, SemanticVersion\toString @record.version + + -- Display changelog + @logger\log update\getChangelog @record, (SemanticVersion\toPacked oldVer) + 1 + @logger\log msgs.performUpdate.reloadNotice + + -- TODO: check handling of private module copies (need extra return value?) + return finish UpdateStatus.Installed, SemanticVersion\toString @record.version + + + ---Reloads this task's record from its config file to pick up an install/update another updater performed + ---concurrently: on a version bump (or a virtual record becoming real) it reloads the module and marks the + ---task updated. Called after waiting on the updater lock, so a task doesn't redo work already done. + refreshRecord: => + with @record + wasVirtual, oldVersion = .virtual, .version + \loadConfig true + if wasVirtual and not .virtual or .version > oldVersion + @updated = true + @ref = ModuleLoader.loadModule @record, @record, false, true if .scriptType == Common.ScriptType.Module + if wasVirtual + @logger\log msgs.refreshRecord.unsetVirtual, Common.terms.scriptType.singular[.scriptType], .name + else + @logger\log msgs.refreshRecord.otherUpdate, Common.terms.scriptType.singular[.scriptType], .name, + SemanticVersion\toString @record.version + +UpdateTask.UpdateStatus = UpdateStatus +UpdateTask.ContextCeiling = ContextCeiling +UpdateTask.UpdateReason = UpdateReason +UpdateTask.SourceChoiceStickiness = SourceChoiceStickiness +UpdateTask.SourceFeedKind = SourceFeedKind +UpdateTask.FeedTrustDecision = FeedTrustDecision + +-- Reveal the private dialog button labels to the unit tests without putting them on the public API. +return UnitTestSuite\withTestExports UpdateTask, {:msgs} diff --git a/modules/l0/DependencyControl/Updater.moon b/modules/l0/DependencyControl/Updater.moon new file mode 100644 index 0000000..64781d1 --- /dev/null +++ b/modules/l0/DependencyControl/Updater.moon @@ -0,0 +1,225 @@ +constants = require "l0.DependencyControl.Constants" +FeedLoader = require "l0.DependencyControl.FeedLoader" +FeedTrust = require "l0.DependencyControl.FeedTrust" +Logger = require "l0.DependencyControl.Logger" +Common = require "l0.DependencyControl.Common" +Lock = require "l0.DependencyControl.Lock" +ModuleLoader = require "l0.DependencyControl.ModuleLoader" +SemanticVersion = require "l0.DependencyControl.SemanticVersion" +UpdateTask = require "l0.DependencyControl.UpdateTask" +DependencyControl = nil + +UPDATER_LOCK_NAMESPACE = "#{constants.DEPCTRL_NAMESPACE}.Updater" +UPDATER_LOCK_RESOURCE_RUN = "run" + +UpdateReason = UpdateTask.UpdateReason +UpdateStatus = UpdateTask.UpdateStatus + +---Coordinates background update checks and update task lifecycle. +---@class Updater +---@field feedTrust FeedTrust The feed-trust model (official + user trust merge, trust queries, mutations). +---@field feedLoader FeedLoader The shared feed loader (owns the feed cache; builds every `UpdateFeed`). +class Updater + @logger = Logger fileBaseName: "DependencyControl.Updater" + + -- Defaults for the config's `updates` section settings this class owns, applied when the key is unset. + ---@type UpdateContextCeiling + @defaultMode = UpdateTask.ContextCeiling.AutoUpdate + @defaultCheckInterval = 302400 + @defaultWaitTimeout = 60 + @defaultOrphanTimeout = 50 + + msgs = { + acquireLock: { + waiting: "Waiting for update initiated by %s to finish..." + } + require: { + macroPassed: "%s is not a module." + upToDate: "Tried to require an update for up-to-date module '%s'." + notFoundDespiteInstalled: "its files were likely moved or deleted after installation, or installed for a different Aegisub setup — reinstalling the module should restore it" + } + 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 *Common.ScriptType.values} + -- one shared feed loader owns the on-disk feed cache and every UpdateFeed construction; feed trust + -- is likewise a singleton so its cache and trust/block invalidations are visible across all consumers + @@feedLoader or= FeedLoader @config, @@logger + @feedLoader = @@feedLoader + @@feedTrust or= FeedTrust @config, @@logger, @@feedLoader + @feedTrust = @@feedTrust + + ---@private + ---@param reason UpdateReason The context asking to run updates. + ---@return boolean enabled True when the configured update mode (`updates.mode`, or its default when unset) allows that context. + __isEnabledFor: (reason) => + UpdateTask.isWithinContextCeiling reason, @config.c.updates.mode or @@defaultMode + + ---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 optional? boolean Treat this as an optional dependency. + ---@param channel? string Update channel to use. + ---@param reason? UpdateReason Why the task runs; the configured update mode must allow it, and it gates interactive prompts. Defaults to `AutoUpdate`, the least interactive. + ---@return UpdateTask? task + ---@return UpdateStatus? code + ---@return string? detail + addTask: (record, targetVersion, addFeeds = {}, optional, channel, reason = UpdateReason.AutoUpdate) => + DependencyControl or= require "l0.DependencyControl" + if record.__class != DependencyControl + depRec = {saveRecordToConfig: false, readGlobalScriptVars: false} + depRec[k] = v for k, v in pairs record + record = DependencyControl depRec + + targetVersionNumber, err = SemanticVersion\toPacked targetVersion + if (err) then return nil, UpdateStatus.InvalidVersion, err + + task = @tasks[record.scriptType][record.namespace] + return if task then with task + .targetVersion = targetVersionNumber + .addFeeds, .optional, .channel, .reason = addFeeds, optional, channel, reason + + -- UpdateTask.new can't reject construction (a constructor's return value is discarded), so guard here + return nil, UpdateStatus.UpdaterDisabled unless @__isEnabledFor reason + return nil, UpdateStatus.InvalidNamespace unless record\validateNamespace! + + task = UpdateTask record, targetVersionNumber, addFeeds, optional, channel, reason, @ + @tasks[record.scriptType][record.namespace] = task + return task + + ---Ensures a module dependency is installed/updated and loadable. + ---@param record Record + ---@param targetVersion? number|string Minimum version to install. + ---@param addFeeds? string[] + ---@param optional? boolean Treat this as an optional dependency. + ---@param channel? string Update channel to use. + ---@param reason? UpdateReason Defaults to `DependencyResolution` — the reason this method exists; a caller (e.g. an installed provider) may pass another to carry its own reason through. + ---@return any ref The loaded module reference, or nil when the module couldn't be provided. + ---@return UpdateStatus? code Outcome of the update run; always present when the ref is nil, with `SkippedOptional` marking a skipped optional dependency rather than a failure. + ---@return string? detail Error detail for a failing code, or the paradox reason when an up-to-date module then can't be loaded. + require: (record, targetVersion, addFeeds, optional, channel, reason = UpdateReason.DependencyResolution) => + @logger\assert record.scriptType == Common.ScriptType.Module, msgs.require.macroPassed, record.name or record.namespace + @logger\log "%s module '%s'...", record.virtual and "Installing required" or "Updating outdated", record.name + task, code, res = @addTask record, targetVersion, addFeeds, optional, channel, reason + code, res = task\run true if task + + if code == UpdateStatus.UpToDate and not task.updated + -- usually we know in advance if a module is up to date so there's no reason to block other updaters + -- but we'll make sure to handle this case gracefully, anyway + @logger\debug msgs.require.upToDate, task.record.name or task.record.namespace + ref = ModuleLoader.loadModule task.record, task.record + return ref if ref + return nil, code, task.record._error or msgs.require.notFoundDespiteInstalled + elseif code >= 0 -- any other non-negative outcome (Installed / AlreadyUpdated / SkippedOptional) + return task.ref, code, res + else -- pass on update errors + return nil, code, res + + ---Performs a periodic non-blocking update check for a managed record. + ---@param record Record + ---@return UpdateStatus status The status code (the task's run result when an update actually runs). + ---@return string? entryPath The resolved entry-point path, returned only with a ProtectedInstall status. + scheduleUpdate: (record) => + unless @__isEnabledFor UpdateReason.AutoUpdate + @logger\trace msgs.scheduleUpdate.updaterDisabled, record.name or record.namespace + return UpdateStatus.UpdaterDisabled + + -- no regular updates for non-existing or unmanaged modules + if record.virtual or record.recordType == Common.RecordType.Unmanaged + return UpdateStatus.Unmanaged + + -- 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.updates.checkInterval or @@defaultCheckInterval) > os.time!) + return UpdateStatus.UpToDate + + record.config.c.lastUpdateCheck = os.time! + record.config\save! + + -- 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, Common.terms.scriptType.singular[record.scriptType], + record.name or record.namespace, entryPath + return UpdateStatus.ProtectedInstall, entryPath + + task = @addTask record -- no need to check for errors, because we've already accounted for those case + @logger\trace msgs.scheduleUpdate.runningUpdate, Common.terms.scriptType.singular[record.scriptType], record.name + return task\run! + + + ---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.updates.waitTimeout or @@defaultWaitTimeout) => + return true if @hasLock + -- lazily build this updater's handle to the shared, cross-process lock on first acquire + @lock or= Lock { + namespace: UPDATER_LOCK_NAMESPACE, resource: UPDATER_LOCK_RESOURCE_RUN + scope: Lock.Scope.Global, holderName: @host, logger: @logger + expiresAfter: @config.c.updates.orphanTimeout or @@defaultOrphanTimeout + } + lock = @lock + + if doWait + holder = lock\getActiveHolder! + @logger\log msgs.acquireLock.waiting, holder.holderName if holder and holder.holderName != @host + + 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 + -- a freshly acquired lock starts a new update pass: expire the feed cache so each feed is refetched + -- once this pass (its snapshot is kept as an offline fallback), then reused by the pass's other tasks + @feedLoader.cache\expireAll! + -- 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[Common.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 + @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 + +-- Re-expose UpdateTask, its version-related enums, and its error-message decoder on the public API, +-- so callers holding only an Updater reference (e.g. ModuleLoader, the Toolbox) can reach them. +Updater.UpdateStatus = UpdateTask.UpdateStatus +Updater.ContextCeiling = UpdateTask.ContextCeiling +Updater.UpdateReason = UpdateTask.UpdateReason +Updater.SourceChoiceStickiness = UpdateTask.SourceChoiceStickiness +Updater.SourceFeedKind = UpdateTask.SourceFeedKind +Updater.FeedTrustDecision = UpdateTask.FeedTrustDecision +Updater.getUpdaterErrorMsg = UpdateTask.getUpdaterErrorMsg +Updater.UpdateTask = UpdateTask +return Updater diff --git a/modules/l0/DependencyControl/ZipArchiver.moon b/modules/l0/DependencyControl/ZipArchiver.moon new file mode 100644 index 0000000..8516086 --- /dev/null +++ b/modules/l0/DependencyControl/ZipArchiver.moon @@ -0,0 +1,170 @@ +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" + +-- Drives .NET's System.IO.Compression ZipArchive directly on Windows, 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! + + -- on 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). + ---@private + ---@return boolean|nil success true on success, nil on failure. + ---@return string|nil err Error message describing the failure, nil on success. + __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" + + -- on 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. + ---@private + ---@return boolean|nil success true on success, nil on failure. + ---@return string|nil err Error message describing the failure, nil on success. + __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/config-schema.moon b/modules/l0/DependencyControl/config-schema.moon new file mode 100644 index 0000000..09bd94c --- /dev/null +++ b/modules/l0/DependencyControl/config-schema.moon @@ -0,0 +1,94 @@ +-- The DependencyControl global config: the cross-cutting sectioned defaults and the one-time migration that +-- lifts a flat pre-sectioned config into those sections. A setting read by a single class keeps its default +-- on that class (e.g. Updater.defaultCheckInterval, FileCache.defaultMaxAge). Only settings shared across +-- subsystems default here. + +Common = require "l0.DependencyControl.Common" +SemanticVersion = require "l0.DependencyControl.SemanticVersion" + +CONFIG_SCHEMA_ID_CURRENT = "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/config/v0.7.0.json" + +-- Per-section defaults. Each section under `sections` is loaded as its own ConfigView with these defaults +-- and handed to the classes of that domain. A section key is kept even when the section has no shared +-- default (e.g. `feeds`), so a view on a section the user hasn't touched still resolves to a table. +sections = { + updates: { + blockPrivateHosts: true + } + feeds: {} + logging: { + defaultLevel: 3 + toFile: true + } + paths: { + config: "?user/config" + log: "?user/log" + cache: "?user/cache" + } +} + +-- Old flat config key -> {section, newKey, transform?}, for the one-time lift from the flat pre-sectioned +-- layout into topic sections; a transform maps the old value onto the new key's value domain. Only v0.6.3 +-- keys appear here: v0.7.0 was never released, so keys added since need no migration and, if present in a +-- dev config, are simply left in place. +keyMap = { + updaterEnabled: {"updates", "mode", (enabled) -> enabled and "auto-update" or "off"} + updateInterval: {"updates", "checkInterval"} + updateWaitTimeout: {"updates", "waitTimeout"} + updateOrphanTimeout: {"updates", "orphanTimeout"} + extraFeeds: {"feeds", "extraFeeds"} + traceLevel: {"logging", "defaultLevel"} + writeLogs: {"logging", "toFile"} + logMaxFiles: {"logging", "maxFiles"} + logMaxAge: {"logging", "maxAge"} + logMaxSize: {"logging", "maxSize"} + configDir: {"paths", "config"} + logDir: {"paths", "log"} +} + +-- v0.6.3 keys removed outright on migration: settings dropped in v0.7.0 with no sectioned replacement. +droppedKeys = {"tryAllFeeds", "dumpFeeds"} + +---Migrates a whole config-file table up to the current schema, in place, when its root `$schema` predates it. +---For a pre-`$schema` config it lifts flat `config`-hive keys into topic sections and rewrites each record's +---pre-0.7 `unmanaged` flag into its recordType and its packed-integer version into a semver string; a config +---that already carries a `$schema`, and any keys none of these cover, are left untouched. +---Shaped as ConfigHandler's migration callback: the handler stamps the new `$schema` when this returns true. +---@param config table The whole config-file table, mutated in place (the `config` hive and the record sections). +---@param currentSchemaId? string The `$schema` found in the file, or nil for a pre-`$schema` (flat) config. +---@param targetSchemaId? string The schema being migrated to (unused here; the handler applies it). +---@return boolean migrated Whether a migration was applied. +migrate = (config, currentSchemaId, targetSchemaId) -> + return false if currentSchemaId -- has a $schema → already sectioned; nothing to lift + + configHive = config.config + if type(configHive) == "table" + for oldKey, dest in pairs keyMap + value = configHive[oldKey] + continue if value == nil + section, newKey, transform = dest[1], dest[2], dest[3] + value = transform value if transform + configHive[section] = {} unless type(configHive[section]) == "table" + configHive[section][newKey] = value + configHive[oldKey] = nil + + configHive[k] = nil for k in *droppedKeys + configHive.formatVersion = nil -- obsolete pre-`$schema` marker, superseded by the root `$schema` + + -- pre-0.7 stored a record's type as a boolean `unmanaged` flag and its version as a packed integer. + -- rewrite the flag as a recordType and the version as a semver string + for section in *Common.ScriptTypeSection.values + records = config[section] + continue unless type(records) == "table" + for _, record in pairs records + continue unless type(record) == "table" + record.recordType = Common.RecordType.Unmanaged if record.unmanaged + record.unmanaged = nil + record.version = SemanticVersion\toString record.version if type(record.version) == "number" + return true + +{ + :CONFIG_SCHEMA_ID_CURRENT + :sections + migration: {:migrate, :keyMap, :droppedKeys} +} 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 <sys/fcntl.h>. + +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..cb5a6c4 --- /dev/null +++ b/modules/l0/DependencyControl/helpers/ffi-windows.moon @@ -0,0 +1,34 @@ +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); + int SetConsoleOutputCP(unsigned int wCodePageID); +]] + +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 + + ---Switches the attached console's output code page to UTF-8. + ---Returns false if kernel32 is unavailable or no console is attached (output is redirected). + ---@return boolean ok Whether the output code page was switched to UTF-8. + setConsoleOutputUTF8: -> haveKernel32 and kernel32.SetConsoleOutputCP(CP_UTF8) != 0 or false +} diff --git a/modules/l0/DependencyControl/helpers/open-url.moon b/modules/l0/DependencyControl/helpers/open-url.moon new file mode 100644 index 0000000..ef6e92f --- /dev/null +++ b/modules/l0/DependencyControl/helpers/open-url.moon @@ -0,0 +1,53 @@ +ffi = require "ffi" +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + +msgs = { + unsafeUrl: "Refusing to open '%s': only http(s) URLs without whitespace or control characters are allowed." + unsupported: "Don't know how to open a URL on this platform (%s)." + openFailed: "The system opener failed for '%s'." +} + +-- Accepts only an http(s) URL free of whitespace and control characters. Those characters have no place in a +-- URL and are the main levers of shell-command injection; rejecting them keeps hostile input from reaching the +-- OS opener at all — even though the openers below never build a shell command line out of the URL. +isSafeUrl = (url) -> + type(url) == "string" and url\match("^https?://") != nil and url\match("[%s%c]") == nil + +-- Builds the POSIX open command, single-quoting the URL so /bin/sh treats it as one literal argument: each +-- embedded ' is closed, escaped and reopened ('\''). Output is discarded. +buildPosixOpenCommand = (launcher, url) -> + quoted = "'" .. url\gsub("'", "'\\''") .. "'" + "#{launcher} #{quoted} >/dev/null 2>&1" + +-- os.execute returns the exit code (Lua 5.1) or a boolean (5.2+/LUA52COMPAT). +execOk = (cmd) -> + r = os.execute cmd + (type(r) == "number" and r == 0) or r == true + +-- The platform opener: (url) -> boolean success; url is pre-validated by isSafeUrl. +opener = switch ffi.os + when "Windows" + ffi.cdef [[ + uintptr_t ShellExecuteA(void*, const char*, const char*, const char*, const char*, int); + ]] + shell32 = ffi.load "shell32" + (url) -> + -- ShellExecuteA hands the URL straight to the OS protocol handler, so no cmd.exe ever parses it. + -- The return is HINSTANCE-like; a value > 32 indicates success. + tonumber(shell32.ShellExecuteA nil, "open", url, nil, nil, 1) > 32 -- 1 = SW_SHOWNORMAL + when "OSX", "Linux", "BSD" + launcher = ffi.os == "OSX" and "open" or "xdg-open" + (url) -> execOk buildPosixOpenCommand launcher, url + +---Opens an http(s) URL in the user's default browser. The URL is validated and never passed through a shell +---command line, so an attacker-controlled feed URL can't inject a command. +---@param url string The URL to open. +---@return true? ok True on success; nil if the URL was rejected, the platform is unsupported, or the open failed. +---@return string? err A human-readable reason on failure; nil on success. +openUrl = (url) -> + return nil, msgs.unsafeUrl\format tostring url unless isSafeUrl url + return nil, msgs.unsupported\format ffi.os unless opener + return nil, msgs.openFailed\format url unless opener url + true + +return UnitTestSuite\withTestExports {open: openUrl}, {:isSafeUrl, :buildPosixOpenCommand} diff --git a/modules/l0/DependencyControl/helpers/resolve-host.moon b/modules/l0/DependencyControl/helpers/resolve-host.moon new file mode 100644 index 0000000..47babe1 --- /dev/null +++ b/modules/l0/DependencyControl/helpers/resolve-host.moon @@ -0,0 +1,92 @@ +-- Resolves a hostname (or IPv6 literal) to its IP addresses via the platform getaddrinfo(3), returning +-- each address as a byte array (4 bytes for IPv4, 16 bytes for IPv6) or nil when it can't resolve. + +ffi = require "ffi" + +-- sockaddr layouts are standardized, so the address bytes sit at the same offsets on every platform. +sockaddrDefs = [[ + struct dc_sockaddr_in { + short sin_family; + unsigned short sin_port; + unsigned char sin_addr[4]; + char sin_zero[8]; + }; + struct dc_sockaddr_in6 { + short sin6_family; + unsigned short sin6_port; + unsigned int sin6_flowinfo; + unsigned char sin6_addr[16]; + unsigned int sin6_scope_id; + }; +]] + +-- The `addrinfo` layout is platform-specific +addrinfoDef = switch ffi.os + when "Windows" + [[ struct dc_addrinfo { int ai_flags; int ai_family; int ai_socktype; int ai_protocol; + size_t ai_addrlen; void* ai_canonname; void* ai_addr; struct dc_addrinfo* ai_next; }; ]] + when "OSX", "BSD" + [[ struct dc_addrinfo { int ai_flags; int ai_family; int ai_socktype; int ai_protocol; + unsigned int ai_addrlen; void* ai_canonname; void* ai_addr; struct dc_addrinfo* ai_next; }; ]] + else + [[ struct dc_addrinfo { int ai_flags; int ai_family; int ai_socktype; int ai_protocol; + unsigned int ai_addrlen; void* ai_addr; void* ai_canonname; struct dc_addrinfo* ai_next; }; ]] + +pcall ffi.cdef, sockaddrDefs +pcall ffi.cdef, addrinfoDef +pcall ffi.cdef, [[ + int getaddrinfo(const char* node, const char* service, const void* hints, struct dc_addrinfo** res); + void freeaddrinfo(struct dc_addrinfo* res); +]] + +local lib +if ffi.os == "Windows" + pcall ffi.cdef, "int WSAStartup(unsigned short version, void* data);" + ok, winsock2 = pcall ffi.load, "ws2_32" + lib = ok and winsock2 or nil +else + lib = ffi.C + +-- AF_INET is 2 on every platform, but AF_INET6 has a different value on each platform. +-- Since host name lookups can only yield IPv4 or IPv6 addresses, we can just treat +-- anything that isn't AF_INET as AF_INET6 and avoid the platform-specific constant. +AF_INET = 2 + +-- Initializes Winsock once on Windows, which is required before calling `getaddrinfo()`. +wsaStarted = false +ensureWinsock2 = -> + return true unless ffi.os == "Windows" + return true if wsaStarted + return false unless lib + -- WSAStartup is ref-counted, so no need to check if it's already started + wsaStarted = pcall -> lib.WSAStartup 0x0202, ffi.new "char[512]" -- request Winsock 2.2 + return wsaStarted + +---Resolves a hostname or IPv6 literal to a list of address byte arrays, or nil when it can't be resolved. +---@param host string The hostname or IPv6 literal to resolve. +---@return integer[][]? addresses One byte array per resolved address (4 bytes IPv4, 16 bytes IPv6). +resolveHost = (host) -> + return nil unless lib and type(host) == "string" and #host > 0 + return nil if ensureWinsock2 and not ensureWinsock2! + + res = ffi.new "struct dc_addrinfo*[1]" + ok, rc = pcall lib.getaddrinfo, host, nil, nil, res + return nil unless ok and rc == 0 + + addresses = {} + node = res[0] + while node != nil + addr = node.ai_addr + if addr != nil + if node.ai_family == AF_INET + sa = ffi.cast "struct dc_sockaddr_in*", addr + addresses[#addresses + 1] = [sa.sin_addr[i] for i = 0, 3] + else + sa = ffi.cast "struct dc_sockaddr_in6*", addr + addresses[#addresses + 1] = [sa.sin6_addr[i] for i = 0, 15] + node = node.ai_next + pcall lib.freeaddrinfo, res[0] + + #addresses > 0 and addresses or nil + +return resolveHost diff --git a/modules/l0/DependencyControl/shims/BadMutex.moon b/modules/l0/DependencyControl/shims/BadMutex.moon new file mode 100644 index 0000000..e756b84 --- /dev/null +++ b/modules/l0/DependencyControl/shims/BadMutex.moon @@ -0,0 +1,24 @@ +-- 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 = { + ---Attempts to acquire the process-scoped mutex without blocking. + ---@return boolean acquired True when the mutex was taken; false when already held or unavailable. + tryLock: -> semaphore\tryLock! + ---Blocks until the process-scoped mutex is acquired. + ---@return boolean issued True when a wait was issued; false only when the mutex is unavailable. + lock: -> semaphore\lock! + ---Releases the process-scoped mutex; call only while holding it. + ---@return boolean issued True when a release was issued; false only when the mutex is unavailable. + 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..363856a --- /dev/null +++ b/modules/l0/DependencyControl/shims/DownloadManager.moon @@ -0,0 +1,87 @@ +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 + + ---Cancels all remaining queued downloads. + cancel: => @downloader\cancel! + ---Removes all queued downloads and resets state; existing `.downloads` references stay valid. + 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..5dcefa9 --- /dev/null +++ b/modules/l0/DependencyControl/test.moon @@ -0,0 +1,59 @@ +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, ... + ffi = require "ffi" + + isWindows = ffi.os == "Windows" + basePath = aegisub.decode_path "?temp/l0.#{DepCtrl.__name}.#{UnitTestSuite.__name}_#{'%04X'\format math.random 0, 16^4-1}" + + -- Each test class lives in its own sibling module under `test/`, loaded via the suite's + -- requireTest helper so the same call resolves in both the Aegisub-default and custom (CI) + -- test locations. Classes needing shared fixtures receive them as arguments (basePath for + -- temp-file paths, DepCtrl for the live record/logger, isWindows for platform branches). + { + Timer: (controls\requireTest "Timer")! + BadMutex: (controls\requireTest "BadMutex")! + Crypto: (controls\requireTest "Crypto")! + ModuleProvider: (controls\requireTest "ModuleProvider") basePath, DepCtrl + Downloader: (controls\requireTest "Downloader") basePath + Common: (controls\requireTest "Common") basePath + FileOps: (controls\requireTest "FileOps") basePath, isWindows + Logger: (controls\requireTest "Logger")! + UnitTestSuite: (controls\requireTest "UnitTestSuite")! + Enum: (controls\requireTest "Enum")! + Accessors: (controls\requireTest "Accessors")! + Finalizer: (controls\requireTest "Finalizer")! + SemanticVersion: (controls\requireTest "SemanticVersion")! + Lock: (controls\requireTest "Lock")! + FileLock: (controls\requireTest "FileLock")! + NamedSemaphore: (controls\requireTest "NamedSemaphore")! + ConfigHandler: (controls\requireTest "ConfigHandler")! + ConfigView: (controls\requireTest "ConfigView")! + ConfigSchema: (controls\requireTest "config-schema")! + ModuleLoader: (controls\requireTest "ModuleLoader")! + Record: (controls\requireTest "Record") basePath + UpdateTask: (controls\requireTest "UpdateTask")! + Updater: (controls\requireTest "Updater")! + FeedTrust: (controls\requireTest "FeedTrust")! + FeedLoader: (controls\requireTest "FeedLoader") basePath, DepCtrl + Host: (controls\requireTest "Host")! + FeedInventory: (controls\requireTest "FeedInventory")! + FeedManager: (controls\requireTest "FeedManager")! + FileCache: (controls\requireTest "FileCache") basePath + ScriptUpdateRecord: (controls\requireTest "ScriptUpdateRecord")! + UpdateFeed: (controls\requireTest "UpdateFeed") basePath, DepCtrl + GitRepository: (controls\requireTest "GitRepository")! + ScriptTargetFilter: (controls\requireTest "ScriptTargetFilter")! + ZipArchiver: (controls\requireTest "ZipArchiver") basePath + JsonSchema: (controls\requireTest "JsonSchema") basePath + FfiPosix: (controls\requireTest "ffi-posix")! + OpenUrl: (controls\requireTest "open-url")! + DownloaderIntegration: (controls\requireTest "integration.Downloader") basePath + LoggerIntegration: (controls\requireTest "integration.Logger") basePath + ZipArchiverIntegration: (controls\requireTest "integration.ZipArchiver") basePath + } diff --git a/modules/l0/DependencyControl/test/Accessors.moon b/modules/l0/DependencyControl/test/Accessors.moon new file mode 100644 index 0000000..7b23188 --- /dev/null +++ b/modules/l0/DependencyControl/test/Accessors.moon @@ -0,0 +1,94 @@ +-- Accessors tests: standardized metatable-backed get/set computed properties. +-- Called from test.moon as: (controls\requireTest "Accessors")! +-> + Accessors = require "l0.DependencyControl.Accessors" + + -- a fresh throwaway class each call, with a read/write property, a read-only one, a plain method, + -- and a raw field set in the constructor, all wired via install + makeWidget = -> + class Widget + new: (v) => @__v = v + doubled: Accessors.property + get: => @__v * 2 + set: (value) => @__v = value / 2 + readonly: Accessors.property + get: => @__v + greet: => "hi" + Accessors.install Widget + + { + _description: "Accessors: standardized metatable-backed get/set computed properties." + + property_rejectsBadSpec: (ut) -> + ut\assertFalse (pcall Accessors.property, 5) -- not a spec table + ut\assertFalse (pcall Accessors.property, {}) -- neither get nor set + ut\assertFalse (pcall Accessors.property, {get: 5}) -- get isn't a function + ut\assertTrue (pcall Accessors.property, {get: ->}) -- getter-only is valid (read-only) + ut\assertTrue (pcall Accessors.property, {set: ->}) -- setter-only is valid + + install_getterDispatches: (ut) -> + w = makeWidget! 10 + ut\assertEquals w.doubled, 20 + + install_setterDispatches: (ut) -> + w = makeWidget! 10 + w.doubled = 30 + ut\assertEquals w.__v, 15 -- setter ran (30 / 2) + ut\assertEquals w.doubled, 30 -- read back through the getter + + install_readOnlyRaises: (ut) -> + w = makeWidget! 10 + ut\assertEquals w.readonly, 10 + ut\assertFalse (pcall -> w.readonly = 1) + + install_fallsThroughToMethodsAndRawFields: (ut) -> + w = makeWidget! 10 + ut\assertEquals w\greet!, "hi" -- a normal method still resolves through the new __index + w.other = 42 -- a non-accessor write goes to a raw field + ut\assertEquals w.other, 42 + + install_recordsRegistry: (ut) -> + Widget = makeWidget! + ut\assertEquals Widget.__accessors.doubled, {get: true, set: true} + ut\assertEquals Widget.__accessors.readonly, {get: true, set: false} + + install_removesSpecFromBase: (ut) -> + Widget = makeWidget! + ut\assertNil rawget Widget.__base, "doubled" -- the spec sentinel isn't served as a raw field + + install_rejectsNonClass: (ut) -> + ut\assertFalse (pcall Accessors.install, 5) -- not a table + ut\assertFalse (pcall Accessors.install, {}) -- a table without a __base + + install_inheritsParentAccessors: (ut) -> + class Base + new: (v) => @__v = v + doubled: Accessors.property + get: => @__v * 2 + Accessors.install Base + class Derived extends Base + tripled: Accessors.property + get: => @__v * 3 + Accessors.install Derived + d = Derived 10 + ut\assertEquals d.doubled, 20 -- inherited accessor, dispatched with the child instance as self + ut\assertEquals d.tripled, 30 -- own accessor + ut\assertEquals Derived.__accessors.doubled, {get: true, set: false} + ut\assertEquals Derived.__accessors.tripled, {get: true, set: false} + + install_readablePropertiesAppearInPairs: (ut) -> + Widget = makeWidget! + w = Widget 10 + seen = {k, v for k, v in pairs w} + ut\assertEquals seen.__v, 10 -- raw fields still iterate + ut\assertEquals seen.doubled, 20 -- a computed property surfaces its getter value + ut\assertEquals seen.readonly, 10 -- a getter-only property surfaces too + + _order: { + "property_rejectsBadSpec" + "install_getterDispatches", "install_setterDispatches", "install_readOnlyRaises" + "install_fallsThroughToMethodsAndRawFields", "install_recordsRegistry" + "install_removesSpecFromBase", "install_rejectsNonClass", "install_inheritsParentAccessors" + "install_readablePropertiesAppearInPairs" + } + } 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..79395ee --- /dev/null +++ b/modules/l0/DependencyControl/test/Common.moon @@ -0,0 +1,209 @@ +-- Common tests: namespace validation, terms, and shared utilities +-- (getAutomationDir, getTestDir, flatten, getObjectHash). +-- Called from Tests.moon as: (require "...test.Common") basePath +(basePath) -> + ffi = require "ffi" + Common = require "l0.DependencyControl.Common" + + { + _description: "Tests for the Common base class: namespace validation, shared terms, and utilities." + + capitalizeTerms: (ut) -> + ut\assertEquals Common.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 + + -- 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" + + -- listIncludes: exact (==) membership in an array + + listIncludes_found: (ut) -> + ut\assertTrue Common.listIncludes {"a", "b", "c"}, "b" + + listIncludes_notFoundAndEmpty: (ut) -> + ut\assertFalse Common.listIncludes {"a", "b"}, "z" + ut\assertFalse Common.listIncludes {}, "a" + + -- 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"} + + -- equals: a cyclic value is treated as matched at that key, not as making the whole tables equal + equals_cyclicRefs: (ut) -> + cyc = (x) -> + t = {:x} + t.self = t + t + ut\assertFalse Common.equals cyc(1), cyc(2) -- a differing sibling key still makes them unequal + ut\assertTrue Common.equals cyc(1), cyc(1) + + -- itemsEqual counts occurrences, so duplicate scalar items match by multiplicity + itemsEqual_duplicateScalars: (ut) -> + ut\assertTrue Common.itemsEqual {1, 1}, {1, 1} + ut\assertTrue Common.itemsEqual {1, 2}, {1, 2} + ut\assertFalse Common.itemsEqual {1, 1}, {1, 2} + ut\assertFalse Common.itemsEqual {1, 1, 2}, {1, 2, 2} + + escapePattern_matchesLiterally: (ut) -> + s = "a-mo.Line[1] (v2)+?*^$%" + escaped = Common.escapePattern s + ut\assertEquals (s\find escaped), 1 + ut\assertNil ("amo.Line[1] (v2)+?*^$%")\find "^" .. escaped -- hyphen no longer quantifies + + escapePattern_plainStringUnchanged: (ut) -> + ut\assertEquals Common.escapePattern("l0_Functional"), "l0_Functional" + + _order: { + "capitalizeTerms", + "validateNamespace_valid", "validateNamespace_multiPart", + "validateNamespace_noDot", "validateNamespace_leadingDot", + "validateNamespace_trailingDot", "validateNamespace_invalidChars", + "validateNamespace_consecutiveDots", + "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", + "listIncludes_found", "listIncludes_notFoundAndEmpty", + "getObjectHash_isHexString", "getObjectHash_deterministic", "getObjectHash_ignoresKeyOrder", + "getObjectHash_nestedOrderIndependent", "getObjectHash_distinguishesContent", + "getObjectHash_typeTagged", "equals_cyclicRefs", "itemsEqual_duplicateScalars", + "escapePattern_matchesLiterally", "escapePattern_plainStringUnchanged" + } + } diff --git a/modules/l0/DependencyControl/test/ConfigHandler.moon b/modules/l0/DependencyControl/test/ConfigHandler.moon new file mode 100644 index 0000000..f445ebf --- /dev/null +++ b/modules/l0/DependencyControl/test/ConfigHandler.moon @@ -0,0 +1,325 @@ +-- ConfigHandler tests: JSON-backed config manager. +-- Called from Tests.moon as: (require "...test.ConfigHandler")! +-> + ConfigHandler = require "l0.DependencyControl.ConfigHandler" + ConfigView = require "l0.DependencyControl.ConfigView" + Lock = require "l0.DependencyControl.Lock" + + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + JSON_MODULE_NAME = "json" + + { + _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 + -- the Global lock's FileLock validates its own lock-file path too, so match the config-path call + validateStub\assertCalledWith "/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 + + -- @get caches by the validated path, so two raw spellings that normalize to one file share a handler + get_cachesByValidatedPath: (ut) -> + canon = "#{aegisub.decode_path '?temp'}/dc_m12a_get_cache.json" + (ut\stub FILEOPS_MODULE_NAME, "validateFullPath")\calls (p) -> canon, nil + h1 = ConfigHandler\get "raw-spelling-one", nil, true + h2 = ConfigHandler\get "raw-spelling-two", nil, true -- validates to the same canonical path + ut\assertNotNil h1 + ut\assertEquals h1, h2 -- same handler, not a duplicate + + -- 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" + + -- load runs the migration callback when the loaded file's $schema differs from the handler's target, + -- then stamps the target $schema and persists the change + load_migratesOnSchemaMismatch: (ut) -> + captured = {} + handler = ConfigHandler nil + handler.filePath = "/config/test.json" + handler.lock = {} + handler.__targetSchemaId = "schema://v2" + handler.__migrate = (config, current, target) -> + captured.current, captured.target = current, target + config.config = {migrated: true} + return true + (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" + (ut\stub io, "open")\calls -> {read: ((h, f) -> "{}"), close: (->)} + (ut\stub JSON_MODULE_NAME, "decode")\returns {config: {flatKey: 1}} -- legacy: no $schema + saveStub = (ut\stub handler, "save")\returns true + ut\assertTrue handler\load! + ut\assertNil captured.current -- the legacy file carried no $schema + ut\assertEquals captured.target, "schema://v2" + ut\assertEquals handler.schemaId, "schema://v2" -- exposed on the handler + ut\assertEquals handler.config["$schema"], "schema://v2" -- stamped into the config + ut\assertTrue handler.config.config.migrated + saveStub\assertCalledOnce! -- migration persisted + + -- load skips migration (and the persist) when the file already carries the target $schema + load_skipsMigrationWhenSchemaMatches: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/config/test.json" + handler.lock = {} + handler.__targetSchemaId = "schema://v2" + migrated = false + handler.__migrate = (config, current, target) -> migrated = true + (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" + (ut\stub io, "open")\calls -> {read: ((h, f) -> "{}"), close: (->)} + (ut\stub JSON_MODULE_NAME, "decode")\returns {["$schema"]: "schema://v2", config: {}} + saveStub = (ut\stub handler, "save")\returns true + ut\assertTrue handler\load! + ut\assertFalse migrated -- current == target: callback never invoked + ut\assertEquals handler.schemaId, "schema://v2" + saveStub\assertNotCalled! + + -- 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", "get_cachesByValidatedPath", + "getHive_exists", "getHive_missing", "getHive_badParent", + "getView_success", "getView_failure", + "getOverlappingViews_wrongHandler", "getOverlappingViews_found", "getOverlappingViews_notFound", + "load_noFilePath", "load_fileNotFound", "load_success", + "load_migratesOnSchemaMismatch", "load_skipsMigrationWhenSchemaMatches", + "save_noFilePath", "save_lockFailed", "save_success", + "save_withViewMissingHive", "save_withViewPopulatedHive", + "purgeHive_removesPath" + } + } diff --git a/modules/l0/DependencyControl/test/ConfigView.moon b/modules/l0/DependencyControl/test/ConfigView.moon new file mode 100644 index 0000000..ac373f9 --- /dev/null +++ b/modules/l0/DependencyControl/test/ConfigView.moon @@ -0,0 +1,252 @@ +-- ConfigView tests: hive accessor and defaults proxy. +-- Called from Tests.moon as: (require "...test.ConfigView")! +-> + ConfigHandler = require "l0.DependencyControl.ConfigHandler" + ConfigView = require "l0.DependencyControl.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" + + -- a partially-populated user section still resolves its unset keys from the section default (the + -- flat->sectioned config migration leaves partial sections behind, so this guards against nil reads) + config_partialSectionFallsThrough: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {nested: {a: "userA"}}} -- only 'a' is set within 'nested' + view = ConfigView handler, {"section"}, {nested: {a: "defA", b: "defB"}} + ut\assertEquals view.config.nested.a, "userA" -- a user-set key wins + ut\assertEquals view.config.nested.b, "defB" -- an unset sibling falls through to the default + + -- writing into a partial section targets the user section only, never materializing the other defaults + config_writeIntoPartialSection: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {nested: {a: "userA"}}} + view = ConfigView handler, {"section"}, {nested: {a: "defA", b: "defB"}} + view.config.nested.b = "newB" + ut\assertEquals view.userConfig.nested.b, "newB" -- written to the user section + ut\assertEquals view.userConfig.nested.a, "userA" -- the existing user value is preserved + ut\assertNil rawget view.userConfig.nested, "c" -- an untouched default is not stored + + -- writing into a section absent from the user config stores only the written key; the section's other + -- defaults stay in code and keep reading through + config_writeIntoAbsentSection: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {}} + view = ConfigView handler, {"section"}, {nested: {a: "defA", b: "defB"}} + view.config.nested.b = "newB" + ut\assertEquals view.userConfig.nested.b, "newB" -- the user section was created with the written key + ut\assertNil rawget view.userConfig.nested, "a" -- an untouched default is not materialized + ut\assertEquals view.config.nested.a, "defA" -- and still reads through + + -- a held section view reads and writes the view's live user config, so it stays valid across a + -- refresh replacing that table + config_heldSectionViewSurvivesRefresh: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {nested: {a: "one"}}} + view = ConfigView handler, {"section"}, {nested: {a: "defA", b: "defB"}} + held = view.config.nested + handler.config = {section: {nested: {a: "two"}}} + view\refresh! + ut\assertEquals held.a, "two" -- reads the refreshed hive + held.b = "newB" + ut\assertEquals view.userConfig.nested.b, "newB" -- writes land in the refreshed hive + + -- regression: constructing a view must not overwrite a populated section that holds table-valued keys + -- (a load once silently wiped trusted/blocked/extra feeds this way) + config_populatedSectionSurvivesConstruction: (ut) -> + handler = ConfigHandler nil + handler.config = {root: {sect: {list: {"userA"}, mode: "never"}}} + view = ConfigView handler, {"root"}, {sect: {list: {}, mode: "always"}} + ut\assertEquals view.userConfig.sect.list, {"userA"} -- user list intact, not replaced by default {} + ut\assertEquals view.userConfig.sect.mode, "never" -- user scalar intact, not reset to "always" + + -- 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 + + -- setFile registers the view with the new handler and detaches it from the old, so the handler's + -- whole-file refreshes reach the view (it was previously left out of the handler's view set) + setFile_registersWithNewHandler: (ut) -> + old = ConfigHandler nil + view = ConfigView old, {"config"} + old.views[view] = true -- as getView would register it + target = "#{aegisub.decode_path '?temp'}/dc_m12b_setfile.json" + ut\assertTrue view\setFile target + ut\assertTrue view.__configHandler.views[view] -- registered with the new handler + ut\assertNil old.views[view] -- and detached from the old one + + _order: { + "new_orphan", "new_withHandler", "new_stringHivePath", "new_tableHivePath", + "isOverlappingView_differentHandler", "isOverlappingView_root", + "isOverlappingView_overlap", "isOverlappingView_disjoint", + "config_readUser", "config_readDefault", "config_write", + "config_partialSectionFallsThrough", "config_writeIntoPartialSection", + "config_writeIntoAbsentSection", "config_heldSectionViewSurvivesRefresh", + "config_populatedSectionSurvivesConstruction", + "refresh_success", + "import_simple", "import_updateOnly", "import_skipPrivate", + "load_noFilePath", "load_delegatesToHandler", + "save_noFilePath", "save_delegatesToHandler", + "delete_purgesAndSaves", "setFile_registersWithNewHandler" + } + } diff --git a/modules/l0/DependencyControl/test/Crypto.moon b/modules/l0/DependencyControl/test/Crypto.moon new file mode 100644 index 0000000..ef123ff --- /dev/null +++ b/modules/l0/DependencyControl/test/Crypto.moon @@ -0,0 +1,40 @@ +-- Crypto tests: pure-Lua SHA-1 against known vectors. +-- Called from Tests.moon as: (require "...test.Crypto")! +-> + Crypto = require "l0.DependencyControl.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" + } + } diff --git a/modules/l0/DependencyControl/test/Downloader.moon b/modules/l0/DependencyControl/test/Downloader.moon new file mode 100644 index 0000000..22eb1e4 --- /dev/null +++ b/modules/l0/DependencyControl/test/Downloader.moon @@ -0,0 +1,256 @@ +-- Downloader engine tests: round-robin scheduling and per-download callbacks, +-- driven by a fake transfer driver so the suite stays fully offline. +-- Called from Tests.moon as: (require "...test.Downloader") basePath +(basePath) -> + Downloader = require "l0.DependencyControl.Downloader" + UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + {:resolveRedirect} = UnitTestSuite\getTestExports Downloader + + -- 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 + + { + _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 + + -- the private-host SSRF guard (enabled per-instance) refuses literal private/loopback addresses, + -- lets public addresses through, and is skippable via the option (all with literal IPs, so offline) + + blockPrivateHosts_refusesPrivateLiteral: (ut) -> + dm = Downloader nil, {blockPrivateHosts: true} + dl, err = dm\addDownload "http://127.0.0.1/x", "#{basePath}_blk" + ut\assertNil dl + ut\assertString err + + blockPrivateHosts_allowsPublicLiteral: (ut) -> + dm = Downloader nil, {blockPrivateHosts: true} + dl = dm\addDownload "http://93.184.216.34/x", "#{basePath}_pub" + ut\assertNotNil dl + + blockPrivateHosts_optOutAllowsPrivate: (ut) -> + dm = Downloader nil, {blockPrivateHosts: false} + dl = dm\addDownload "http://127.0.0.1/x", "#{basePath}_optout" + ut\assertNotNil dl + + -- with the guard on, a non-http(s) URL is refused even though it has no host for the private-IP check + blockPrivateHosts_refusesNonHttpScheme: (ut) -> + dm = Downloader nil, {blockPrivateHosts: true} + dl, err = dm\addDownload "file:///etc/passwd", "#{basePath}_file" + ut\assertNil dl + ut\assertString err + + -- resolveRedirect: a Location header may be absolute, protocol-relative, root-relative, or path-relative + resolveRedirect_resolvesAllForms: (ut) -> + ut\assertEquals resolveRedirect("http://a/b/c", "https://x/y"), "https://x/y" -- absolute wins + ut\assertEquals resolveRedirect("http://a.com/b/c", "/new"), "http://a.com/new" -- root-relative + ut\assertEquals resolveRedirect("https://a.com/b", "//o.com/z"), "https://o.com/z" -- protocol-relative + ut\assertEquals resolveRedirect("https://a.com/d/p.json", "s.json"), "https://a.com/d/s.json" -- path-relative + + _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", + "blockPrivateHosts_refusesPrivateLiteral", "blockPrivateHosts_allowsPublicLiteral", + "blockPrivateHosts_optOutAllowsPrivate", "blockPrivateHosts_refusesNonHttpScheme", + "resolveRedirect_resolvesAllForms" + } + } diff --git a/modules/l0/DependencyControl/test/Enum.moon b/modules/l0/DependencyControl/test/Enum.moon new file mode 100644 index 0000000..1c05139 --- /dev/null +++ b/modules/l0/DependencyControl/test/Enum.moon @@ -0,0 +1,118 @@ +-- Enum tests: immutable enumeration types with reverse lookup. +-- Called from Tests.moon as: (require "...test.Enum")! +-> + Enum = require "l0.DependencyControl.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, "1 (Foo)" + + describe_list: (ut) -> + e = Enum "MyEnum", {Foo: 1, Bar: 2} + result = e\describe {1, 2}, nil, false + ut\assertTable result + ut\assertEquals #result, 2 + + describe_join: (ut) -> + e = Enum "MyEnum", {Foo: 1, Bar: 2} + result = e\describe {1, 2}, (k) -> k + ut\assertString result + ut\assertContains result, "Foo" + ut\assertContains result, "Bar" + + describe_unknown: (ut) -> + e = Enum "MyEnum", {Foo: 1} + result, err = pcall e\describe, 99 + ut\assertFalse 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" + } + } diff --git a/modules/l0/DependencyControl/test/FeedInventory.moon b/modules/l0/DependencyControl/test/FeedInventory.moon new file mode 100644 index 0000000..bedf4c4 --- /dev/null +++ b/modules/l0/DependencyControl/test/FeedInventory.moon @@ -0,0 +1,324 @@ +-- FeedInventory tests: gathering reachable feeds from config + official trust lists, with per-feed +-- provenance (source tags) and trust status. Network-free; feedTrust and config are stubbed. +-- Called from test.moon as: (controls\requireTest "FeedInventory")! +() -> + FeedInventory = require "l0.DependencyControl.FeedInventory" + FeedTrust = require "l0.DependencyControl.FeedTrust" + constants = require "l0.DependencyControl.Constants" + UpdateTask = require "l0.DependencyControl.UpdateTask" + + Provenance = FeedInventory.Provenance + TrustStatus = FeedTrust.TrustStatus + CrawlLimit = FeedInventory.CrawlLimit + SourceFeedKind = UpdateTask.SourceFeedKind + + -- a fake FeedTrust; opts: official (set), trusted (set), blocked (set), status (url -> FeedTrustStatus), + -- blockEntries (url -> entry), fetchUntrustedFeeds (policy string), prompter (url -> bool for the prompt + -- policy). getTrustStatus returns (status, blockEntry). isTrusted/isBlocked/shouldFetch back the crawl's + -- follow decision; shouldFetch mirrors the real gate (Allow -> true, Deny -> false, Prompt -> prompter). + makeFeedTrust = (opts = {}) -> + { + getOfficialTrustedFeeds: => opts.official or {} + isTrusted: (url) => (opts.trusted or {})[url] and true or false + isBlocked: (url) => (opts.blocked or {})[url] and true or false + getTrustStatus: (url) => + return TrustStatus.Blocked, (opts.blockEntries or {})[url] if (opts.blocked or {})[url] + (opts.status or {})[url] or TrustStatus.Untrusted + getFetchDecision: (url) => + return FeedTrust.FetchDecision.Deny if (opts.blocked or {})[url] + return FeedTrust.FetchDecision.Allow if (opts.trusted or {})[url] + switch opts.fetchUntrustedFeeds + when "never" then FeedTrust.FetchDecision.Deny + when "prompt" then FeedTrust.FetchDecision.Prompt + else FeedTrust.FetchDecision.Allow + shouldFetch: (url) => + switch @getFetchDecision url + when FeedTrust.FetchDecision.Allow then true + when FeedTrust.FetchDecision.Deny then false + else opts.prompter and opts.prompter(url) and true or false + } + + -- a fake feed loader: `.cache\getMeta` reports last-fetch metadata and `\load url` returns a fake feed. + -- Both default to "nothing known"; override per test via opts.meta (url -> meta) and opts.load (url -> feed). + makeFeedLoader = (opts = {}) -> + { + cache: {getMeta: (_, key) -> opts.meta and opts.meta[key]} + load: (_, url) -> opts.load and opts.load url + } + + -- section a flat test config into the live sectioned layout (feeds/updates/paths + the macros/modules registries) + makeInventory = (configC = {}, feedTrustOpts, feedLoader) -> + sectioned = { + feeds: {extraFeeds: configC.extraFeeds, trustedFeeds: configC.trustedFeeds, crawlLimits: configC.feedCrawlLimits} + updates: {blockPrivateHosts: configC.updaterBlockPrivateHosts} + paths: {cache: "?user/cache"} + macros: configC.macros + modules: configC.modules + } + FeedInventory {c: sectioned}, (makeFeedTrust feedTrustOpts), (feedLoader or makeFeedLoader!) + gatherFeeds = (configC, feedTrustOpts) -> makeInventory(configC, feedTrustOpts)\gather! + + -- a fake FeedLoader over a fixed {url -> knownFeeds list} map: load(url) yields a feed exposing those + -- knownFeeds; an unmapped url yields a feed with no `.data`, i.e. unreachable (like a failed fetch) + crawlLoader = (map) -> + makeFeedLoader { + load: (url) -> + known = map[url] + {data: known, getKnownFeeds: => known or {}} + } + + -- index a gathered feed list by url for assertions + byUrl = (feeds) -> {f.url, f for f in *feeds} + + -- the first crawl truncation recorded against a given feed + truncationFor = (stats, feedUrl) -> + matches = [t for t in *stats.truncations when t.feed == feedUrl] + matches[1] + + { + _description: "FeedInventory: reachable-feed gathering with provenance and trust status." + + -- each config/official/package source contributes a feed tagged with the matching provenance + gather_tagsEachSource: (ut) -> + m = byUrl gatherFeeds { + extraFeeds: {"feed://extra"} + trustedFeeds: {"feed://trusted"} + macros: { + "a.b": { + feed: "feed://declared" + userFeed: "feed://override" + requiredModules: {{moduleName: "x", feed: "feed://advertised"}} + } + } + modules: {} + }, official: {[constants.DEPCTRL_FEED_URL]: true, "feed://known": true} + ut\assertEquals m[constants.DEPCTRL_FEED_URL].provenance, {Provenance.OfficialDepCtrl} + ut\assertEquals m["feed://known"].provenance, {Provenance.OfficialKnown} + ut\assertEquals m["feed://extra"].provenance, {Provenance.UserExtra} + -- a trustedFeeds-only feed isn't a discovery source: no provenance, just the inTrustedFeeds trust flag + ut\assertEquals m["feed://trusted"].provenance, {} + ut\assertTrue m["feed://trusted"].inTrustedFeeds + ut\assertEquals m["feed://declared"].provenance, {Provenance.PackageDeclared} + ut\assertEquals m["feed://override"].provenance, {Provenance.PackageOverride} + ut\assertEquals m["feed://advertised"].provenance, {Provenance.DependencyAdvertised} + -- package-sourced feeds record the contributing namespace; others have none + ut\assertEquals m["feed://declared"].packages, {"a.b"} + ut\assertEquals m["feed://override"].packages, {"a.b"} + ut\assertEquals m["feed://advertised"].packages, {"a.b"} + ut\assertEquals m["feed://extra"].packages, {} + + -- a feed reached via several sources collects every provenance, in enum order; trustedFeeds membership + -- adds no provenance but sets the inTrustedFeeds flag + gather_mergesProvenance: (ut) -> + m = byUrl gatherFeeds { + extraFeeds: {"feed://x"} + trustedFeeds: {"feed://x"} + macros: {"a.b": {feed: "feed://x"}} + }, official: {"feed://x": true} + ut\assertEquals m["feed://x"].provenance, + {Provenance.OfficialKnown, Provenance.UserExtra, Provenance.PackageDeclared} + ut\assertTrue m["feed://x"].inTrustedFeeds + + -- gather surfaces the status FeedTrust reports for each feed (the classification itself lives in the + -- FeedTrust suite); a non-blocked feed has no blockedBy + gather_surfacesTrustStatus: (ut) -> + m = byUrl gatherFeeds {extraFeeds: {"feed://o", "feed://u"}}, + status: {"feed://o": TrustStatus.TrustedOfficial, "feed://u": TrustStatus.TrustedUser} + ut\assertEquals m["feed://o"].trustStatus, TrustStatus.TrustedOfficial + ut\assertEquals m["feed://u"].trustStatus, TrustStatus.TrustedUser + ut\assertNil m["feed://o"].blockedBy + + -- a blocked feed carries the block entry that matched it (the second value from getTrustStatus) + gather_blockedByEntry: (ut) -> + blockEntry = {url: "feed://bad", matchMode: "prefix", reason: "malware", isOfficial: true} + m = byUrl gatherFeeds {extraFeeds: {"feed://bad"}}, + blocked: {"feed://bad": true}, blockEntries: {"feed://bad": blockEntry} + ut\assertEquals m["feed://bad"].trustStatus, TrustStatus.Blocked + ut\assertEquals m["feed://bad"].blockedBy, blockEntry + + -- a feed is marked inUse when it's a package's effective source (override if set, else declared feed) + gather_marksInUse: (ut) -> + m = byUrl gatherFeeds { + macros: { + "a.plain": {feed: "feed://plain"} + "a.overridden": {feed: "feed://declared", userFeed: "feed://override"} + } + }, {} + ut\assertTrue m["feed://plain"].inUse -- declared with no override -> effective source + ut\assertTrue m["feed://override"].inUse -- the override is the effective source + ut\assertNil m["feed://declared"].inUse -- declared but overridden -> not the effective source + + -- getPackagesSourcedFrom / getEffectiveSource: a package's remembered currentSource (resolved per kind), else its + -- override, else its declared feed + getPackagesSourcedFrom_getEffectiveSource: (ut) -> + inv = makeInventory { + macros: { + "a.self": {feed: "feed://self", currentSource: {feedSource: SourceFeedKind.SelfDeclared}} + "a.user": {feed: "feed://declared", userFeed: "feed://user", currentSource: {feedSource: SourceFeedKind.UserFeed}} + "a.other": {feed: "feed://x", currentSource: {feedSource: SourceFeedKind.Other, feedUrl: "feed://literal"}} + "a.plain": {feed: "feed://plain"} -- no currentSource -> falls back to feed + "a.override": {feed: "feed://d2", userFeed: "feed://o2"} -- no currentSource -> falls back to userFeed + } + modules: { + "m.prov": {feed: "feed://provider"} + "m.viaProv": {feed: "feed://y", currentSource: {feedSource: SourceFeedKind.Provider, provider: {namespace: "m.prov"}}} + } + }, {} + ut\assertEquals inv\getPackagesSourcedFrom("feed://self"), {"a.self"} -- self-declared -> own feed + ut\assertEquals inv\getPackagesSourcedFrom("feed://user"), {"a.user"} -- user-feed kind -> the override + ut\assertEquals inv\getPackagesSourcedFrom("feed://declared"), {} -- declared, but sourced elsewhere + ut\assertEquals inv\getPackagesSourcedFrom("feed://literal"), {"a.other"} -- other -> literal feedUrl + ut\assertEquals inv\getPackagesSourcedFrom("feed://plain"), {"a.plain"} -- fallback: no currentSource -> feed + ut\assertEquals inv\getPackagesSourcedFrom("feed://o2"), {"a.override"} -- fallback: no currentSource -> userFeed + ut\assertEquals inv\getPackagesSourcedFrom("feed://d2"), {} -- declared but overridden -> not effective + -- provider resolves to m.prov's feed; m.prov itself (no currentSource) also falls back to that same feed + ut\assertEquals inv\getPackagesSourcedFrom("feed://provider"), {"m.prov", "m.viaProv"} + + -- an empty config with no official feeds yields no entries + gather_empty: (ut) -> + ut\assertEquals #gatherFeeds({}, {}), 0 + + -- crawl discovers transitively-advertised feeds, tagging them TransitiveKnown and recording the advertiser + crawl_discoversTransitively: (ut) -> + loader = crawlLoader {"root": {"mid", "leaf"}, "mid": {"deep"}} + inv = makeInventory {extraFeeds: {"root"}}, {trusted: {"root": true, "mid": true}}, loader + feeds, stats = inv\crawl! + m = byUrl feeds + ut\assertEquals m["mid"].provenance, {Provenance.TransitiveKnown} + ut\assertEquals m["leaf"].provenance, {Provenance.TransitiveKnown} + ut\assertEquals m["deep"].provenance, {Provenance.TransitiveKnown} + ut\assertEquals m["mid"].advertisedBy, {"root"} + ut\assertEquals m["deep"].advertisedBy, {"mid"} + ut\assertFalse stats.truncated -- nothing was capped + + -- with fetchUntrustedFeeds="never", an untrusted feed is recorded (advertised) but not fetched/expanded + crawl_neverDoesNotFetchUntrusted: (ut) -> + loader = crawlLoader {"root": {"untrusted"}, "untrusted": {"deep"}} + inv = makeInventory {extraFeeds: {"root"}}, {trusted: {"root": true}, fetchUntrustedFeeds: "never"}, loader + m = byUrl inv\crawl! + ut\assertEquals m["untrusted"].provenance, {Provenance.TransitiveKnown} -- recorded (advertised) + ut\assertNil m["deep"] -- not fetched -> not discovered + + -- a blocked root is gated like any other feed: recorded but never fetched, so what it advertises + -- isn't discovered (a blocked feed is always denied, even when it's a configured discovery root) + crawl_blockedRootNotFetched: (ut) -> + loader = crawlLoader {"blockedRoot": {"child"}} + inv = makeInventory {extraFeeds: {"blockedRoot"}}, {blocked: {"blockedRoot": true}}, loader + m = byUrl inv\crawl! + ut\assertNotNil m["blockedRoot"] -- still listed (user extra feed) + ut\assertNil m["child"] -- root not fetched -> its advertised feed not discovered + + -- with fetchUntrustedFeeds="prompt", an untrusted feed is followed only when the prompter confirms it + crawl_promptFollowsOnlyConfirmedUntrusted: (ut) -> + asked = {} + prompter = (url) -> + asked[#asked + 1] = url + url == "yes" + -- deepYes is trusted so following the confirmed "yes" doesn't itself prompt again + loader = crawlLoader { + "root": {"yes", "no"} + "yes": {"deepYes"}, "no": {"deepNo"} + } + inv = makeInventory {extraFeeds: {"root"}}, {trusted: {"root": true, "deepYes": true}, fetchUntrustedFeeds: "prompt", :prompter}, loader + m = byUrl inv\crawl! + ut\assertNotNil m["yes"] -- both untrusted feeds are recorded (advertised) + ut\assertNotNil m["no"] + ut\assertNotNil m["deepYes"] -- confirmed -> followed -> its child is discovered + ut\assertNil m["deepNo"] -- declined -> not followed + ut\assertEquals #asked, 2 -- only the two untrusted feeds were asked, once each + + -- the per-subtree budget caps how many untrusted feeds are fetched from one root + crawl_boundsUntrustedPerRoot: (ut) -> + loader = crawlLoader { + "root": {"u1", "u2", "u3"} + "u1": {"d1"}, "u2": {"d2"}, "u3": {"d3"} + } + inv = makeInventory {extraFeeds: {"root"}, feedCrawlLimits: {[CrawlLimit.PerRoot]: 2}}, {trusted: {"root": true}}, loader + feeds, stats = inv\crawl! + m = byUrl feeds + -- all three untrusted feeds are recorded; only two are fetched, so only two children are discovered + reached = 0 + for d in *{"d1", "d2", "d3"} + reached += 1 if m[d] + ut\assertEquals reached, 2 + -- the third untrusted feed the root advertised was dropped by the per-root budget + rootDrop = truncationFor stats, "root" + ut\assertEquals rootDrop.limit, CrawlLimit.PerRoot + ut\assertEquals rootDrop.limitValue, 2 + ut\assertEquals rootDrop.droppedUrls, {"u3"} + + -- the per-feed cap bounds how many untrusted feeds a single feed contributes + crawl_boundsUntrustedPerFeed: (ut) -> + loader = crawlLoader {"root": {"u1", "u2"}} + inv = makeInventory {extraFeeds: {"root"}, feedCrawlLimits: {[CrawlLimit.PerFeed]: 1}}, {trusted: {"root": true}}, loader + feeds, stats = inv\crawl! + m = byUrl feeds + ut\assertNotNil m["u1"] + ut\assertNil m["u2"] + ut\assertEquals #stats.truncations, 1 + t = stats.truncations[1] + ut\assertEquals t.limit, CrawlLimit.PerFeed + ut\assertEquals t.feed, "root" + ut\assertEquals t.route, {"root"} + ut\assertEquals t.dropped, 1 + ut\assertEquals t.droppedUrls, {"u2"} + + -- a feed at the depth cap is left unfetched and reported with its route from the root + crawl_reportsDepthTruncation: (ut) -> + loader = crawlLoader {"root": {"a"}, "a": {"b"}} + inv = makeInventory {extraFeeds: {"root"}, feedCrawlLimits: {[CrawlLimit.Depth]: 1}}, {trusted: {"root": true, "a": true}}, loader + feeds, stats = inv\crawl! + m = byUrl feeds + ut\assertNil m["b"] -- "a" sat at the depth cap, so its knownFeeds were never read + t = truncationFor stats, "a" + ut\assertEquals t.limit, CrawlLimit.Depth + ut\assertEquals t.route, {"root", "a"} + ut\assertEquals t.dropped, 0 + + -- crawl marks each feed it successfully fetched; an advertised feed it can't load stays unmarked + crawl_marksFetched: (ut) -> + loader = crawlLoader {"root": {"mid", "dead"}, "mid": {}} + inv = makeInventory {extraFeeds: {"root"}}, {trusted: {"root": true, "mid": true}}, loader + m = byUrl inv\crawl! + ut\assertTrue m["root"].fetched -- config root, loaded + ut\assertTrue m["mid"].fetched -- trusted, loaded (even with empty knownFeeds) + ut\assertNil m["dead"].fetched -- advertised but the loader returns nil -> unreachable + + -- a feed known only through trustedFeeds is trust-only: visible in the inventory but not a crawl root, + -- so its knownFeeds are never expanded + crawl_trustedFeedsNotCrawlRoot: (ut) -> + loader = crawlLoader {"tf": {"child"}} + inv = makeInventory {trustedFeeds: {"tf"}}, {trusted: {"tf": true}}, loader + m = byUrl inv\crawl! + ut\assertTrue m["tf"].inTrustedFeeds + ut\assertEquals m["tf"].provenance, {} -- not a discovery source + ut\assertNil m["tf"].fetched -- never fetched (not a root) + ut\assertNil m["child"] -- so its advertised feed isn't discovered + + -- gather stamps each feed with its last-fetch time from the persistent cache; an uncached feed has none + gather_stampsLastFetchedFromCache: (ut) -> + loader = makeFeedLoader {meta: {"feed://cached": {key: "feed://cached", cachedAt: 1700000000, latestFile: "x.json"}}} + m = byUrl (makeInventory {extraFeeds: {"feed://cached", "feed://uncached"}}, {}, loader)\gather! + ut\assertEquals m["feed://cached"].lastFetchedAt, 1700000000 + ut\assertNil m["feed://uncached"].lastFetchedAt + + -- a feed advertised by DepCtrl's own feed is official-known, not transitive-known + crawl_depCtrlFeedTagsOfficialKnown: (ut) -> + loader = crawlLoader {[constants.DEPCTRL_FEED_URL]: {"known"}} + inv = makeInventory {}, { + official: {[constants.DEPCTRL_FEED_URL]: true} + trusted: {[constants.DEPCTRL_FEED_URL]: true, "known": true} + }, loader + m = byUrl inv\crawl! + ut\assertEquals m["known"].provenance, {Provenance.OfficialKnown} + + _order: { + "gather_tagsEachSource", "gather_mergesProvenance", "gather_surfacesTrustStatus", "gather_blockedByEntry" + "gather_marksInUse", "getPackagesSourcedFrom_getEffectiveSource", "gather_empty" + "crawl_discoversTransitively", "crawl_neverDoesNotFetchUntrusted", "crawl_blockedRootNotFetched" + "crawl_promptFollowsOnlyConfirmedUntrusted" + "crawl_boundsUntrustedPerRoot", "crawl_boundsUntrustedPerFeed", "crawl_reportsDepthTruncation" + "crawl_marksFetched", "crawl_trustedFeedsNotCrawlRoot" + "gather_stampsLastFetchedFromCache", "crawl_depCtrlFeedTagsOfficialKnown" + } + } diff --git a/modules/l0/DependencyControl/test/FeedLoader.moon b/modules/l0/DependencyControl/test/FeedLoader.moon new file mode 100644 index 0000000..3b79374 --- /dev/null +++ b/modules/l0/DependencyControl/test/FeedLoader.moon @@ -0,0 +1,41 @@ +-- FeedLoader tests: builds the shared feed cache from config and hands out UpdateFeed instances wired to +-- it. Network-free — feeds are constructed with autoLoad off, so nothing is fetched. +-- Called from test.moon as: (require "…test.FeedLoader") basePath, DepCtrl +(basePath, DepCtrl) -> + FeedLoader = require "l0.DependencyControl.FeedLoader" + FileOps = require "l0.DependencyControl.FileOps" + constants = require "l0.DependencyControl.Constants" + + url = "https://example.com/feed.json" + + -- a FeedLoader over a temp cache base; opts override the config's feed settings + make = (opts = {}) -> + config = {c: { + paths: {cache: FileOps.joinPath basePath, "feedloader"} + feeds: {cacheMaxAge: opts.cacheMaxAge or 4242} + updates: {blockPrivateHosts: opts.blockPrivateHosts} + }} + FeedLoader config, DepCtrl.logger + + { + _description: "FeedLoader: shared feed cache construction and UpdateFeed wiring." + + -- the cache lives under DepCtrl's namespace in a `feeds` subdir of the configured cache base + new_opensFeedCacheUnderNamespace: (ut) -> + loader = make! + ut\assertNotNil loader.cache + ut\assertContains loader.cache.cacheDir, constants.DEPCTRL_NAMESPACE + ut\assertMatches loader.cache.cacheDir, "feeds$" + + -- load injects the shared cache and the configured fetch policy + load_wiresSharedCacheAndPolicy: (ut) -> + loader = make {blockPrivateHosts: true} + feed = loader\load url, {autoLoad: false} + ut\assertIs feed.config.cache, loader.cache + ut\assertTrue feed.config.blockPrivateHosts + ut\assertNil feed.data -- autoLoad off: constructed but not fetched + + _order: { + "new_opensFeedCacheUnderNamespace", "load_wiresSharedCacheAndPolicy" + } + } diff --git a/modules/l0/DependencyControl/test/FeedManager.moon b/modules/l0/DependencyControl/test/FeedManager.moon new file mode 100644 index 0000000..ca3af69 --- /dev/null +++ b/modules/l0/DependencyControl/test/FeedManager.moon @@ -0,0 +1,161 @@ +-- FeedManager tests: the actions the Manage Feeds UI offers per feed, action execution (via a stub FeedTrust), +-- the DepCtrl Browser deep-link, and row assembly. No network. +() -> + FeedManager = require "l0.DependencyControl.FeedManager" + FeedInventory = require "l0.DependencyControl.FeedInventory" + FeedTrust = require "l0.DependencyControl.FeedTrust" + constants = require "l0.DependencyControl.Constants" + + Provenance = FeedInventory.Provenance + TrustStatus = FeedTrust.TrustStatus + FeedAction = FeedManager.FeedAction + BlockMatchMode = FeedTrust.BlockMatchMode + + actionsFor = (entry) -> FeedManager.getAvailableActions entry + + -- a fake FeedTrust that records the mutator calls the manager delegates, in order + makeManager = -> + calls = {} + feedTrust = { + trust: (url) => calls[#calls + 1] = {"trust", url} + untrust: (url) => calls[#calls + 1] = {"untrust", url} + unblock: (url) => calls[#calls + 1] = {"unblock", url} + removeExtraFeed: (url) => calls[#calls + 1] = {"removeExtraFeed", url} + block: (url, opts) => calls[#calls + 1] = {"block", url, opts} + } + FeedManager(feedTrust), calls + + { + _description: "FeedManager: per-feed available actions, the DepCtrl Browser link, and action execution." + + -- an untrusted feed can be trusted, blocked, or viewed + availableActions_untrusted: (ut) -> + entry = {url: "feed://x", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.Untrusted} + ut\assertEquals actionsFor(entry), {FeedAction.Trust, FeedAction.Block, FeedAction.OpenBrowser} + + -- a feed in a user list (here extraFeeds) can be blocked, removed, or viewed (not re-trusted) + availableActions_trustedUser: (ut) -> + entry = {url: "feed://x", provenance: {Provenance.UserExtra}, trustStatus: TrustStatus.TrustedUser} + ut\assertEquals actionsFor(entry), {FeedAction.Block, FeedAction.Remove, FeedAction.OpenBrowser} + + -- a trustedFeeds-only feed carries no provenance but is still removable via the inTrustedFeeds flag + availableActions_trustedFeedsOnly: (ut) -> + entry = {url: "feed://t", provenance: {}, inTrustedFeeds: true, trustStatus: TrustStatus.TrustedUser} + ut\assertEquals actionsFor(entry), {FeedAction.Block, FeedAction.Remove, FeedAction.OpenBrowser} + + -- the DepCtrl bootstrap feed can only be viewed: blocking it would collapse trust, and it isn't user-owned + availableActions_bootstrapFeed: (ut) -> + entry = {url: constants.DEPCTRL_FEED_URL, provenance: {Provenance.OfficialDepCtrl}, trustStatus: TrustStatus.TrustedOfficial} + ut\assertEquals actionsFor(entry), {FeedAction.OpenBrowser} + + -- an official-known trusted feed can be blocked (the only way to stop trusting it) or viewed, not removed + availableActions_trustedOfficial: (ut) -> + entry = {url: "feed://known", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.TrustedOfficial} + ut\assertEquals actionsFor(entry), {FeedAction.Block, FeedAction.OpenBrowser} + + -- a user's exact block can be lifted from the list view + availableActions_blockedUserExact: (ut) -> + entry = {url: "feed://b", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.Blocked, blockedBy: {url: "feed://b", matchMode: BlockMatchMode.Exact, isOfficial: false}} + ut\assertEquals actionsFor(entry), {FeedAction.Unblock, FeedAction.OpenBrowser} + + -- a prefix (or official) block isn't liftable per-feed from the list view -> only Open Browser + availableActions_blockedPrefix: (ut) -> + entry = {url: "feed://b", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.Blocked, blockedBy: {url: "feed://", matchMode: BlockMatchMode.Prefix, isOfficial: true}} + ut\assertEquals actionsFor(entry), {FeedAction.OpenBrowser} + + -- a feed that's both user-listed and under the user's exact block offers Unblock and Remove + availableActions_blockedAndUserListed: (ut) -> + entry = {url: "feed://b", provenance: {Provenance.UserExtra}, trustStatus: TrustStatus.Blocked, blockedBy: {url: "feed://b", matchMode: BlockMatchMode.Exact, isOfficial: false}} + ut\assertEquals actionsFor(entry), {FeedAction.Unblock, FeedAction.Remove, FeedAction.OpenBrowser} + + -- the browser link slugs the feed URL by the first 7 hex chars of its SHA-1 + getBrowserUrl_slug: (ut) -> + ut\assertEquals FeedManager.getBrowserUrl("abc"), + "https://typesettingtools.github.io/depctrl-browser/feeds/a9993e3/" + + -- Trust delegates to feedTrust\trust and reports a mutation + applyAction_trust: (ut) -> + mgr, calls = makeManager! + entry = {url: "feed://x", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.Untrusted} + ut\assertTrue mgr\applyAction FeedAction.Trust, entry + ut\assertEquals calls, {{"trust", "feed://x"}} + + -- Block adds an exact block carrying the given reason + applyAction_block: (ut) -> + mgr, calls = makeManager! + entry = {url: "feed://x", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.Untrusted} + mgr\applyAction FeedAction.Block, entry, {reason: "malware"} + ut\assertEquals calls[1][1], "block" + ut\assertEquals calls[1][2], "feed://x" + ut\assertEquals calls[1][3].matchMode, BlockMatchMode.Exact + ut\assertEquals calls[1][3].reason, "malware" + + -- Remove clears both user lists (offered here via the inTrustedFeeds flag alone) + applyAction_removeClearsBothLists: (ut) -> + mgr, calls = makeManager! + entry = {url: "feed://x", provenance: {}, inTrustedFeeds: true, trustStatus: TrustStatus.TrustedUser} + mgr\applyAction FeedAction.Remove, entry + ut\assertEquals calls, {{"removeExtraFeed", "feed://x"}, {"untrust", "feed://x"}} + + -- Unblock lifts the user's exact block + applyAction_unblock: (ut) -> + mgr, calls = makeManager! + entry = {url: "feed://b", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.Blocked, blockedBy: {url: "feed://b", matchMode: BlockMatchMode.Exact, isOfficial: false}} + mgr\applyAction FeedAction.Unblock, entry + ut\assertEquals calls, {{"unblock", "feed://b"}} + + -- an action the feed doesn't offer (blocking the bootstrap feed) is refused and mutates nothing + applyAction_refusesGuarded: (ut) -> + mgr, calls = makeManager! + entry = {url: constants.DEPCTRL_FEED_URL, provenance: {Provenance.OfficialDepCtrl}, trustStatus: TrustStatus.TrustedOfficial} + ut\assertFalse mgr\applyAction FeedAction.Block, entry + ut\assertEquals #calls, 0 + + -- Open Browser performs no mutation + applyAction_openBrowserNoOp: (ut) -> + mgr, calls = makeManager! + entry = {url: "feed://x", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.Untrusted} + ut\assertFalse mgr\applyAction FeedAction.OpenBrowser, entry + ut\assertEquals #calls, 0 + + -- rows attach actions, the browser link, removability, reachability and last-fetch time, sorted by URL + buildRows_assemblesAndSorts: (ut) -> + entries = { + {url: "feed://b", provenance: {Provenance.OfficialKnown}, trustStatus: TrustStatus.Untrusted, fetched: true, lastFetchedAt: 1700000000} + {url: "feed://a", provenance: {Provenance.UserExtra}, trustStatus: TrustStatus.TrustedUser, inUse: true} + } + rows = FeedManager.buildRows entries + ut\assertEquals rows[1].url, "feed://a" -- URL-sorted, independent of input order + ut\assertEquals rows[2].url, "feed://b" + ut\assertEquals rows[1].actions, {FeedAction.Block, FeedAction.Remove, FeedAction.OpenBrowser} + ut\assertTrue rows[1].removable + ut\assertEquals rows[1].browserUrl, FeedManager.getBrowserUrl "feed://a" + ut\assertTrue rows[2].reachable -- surfaced from entry.fetched + ut\assertNil rows[1].reachable + ut\assertTrue rows[1].inUse -- surfaced from entry.inUse + ut\assertNil rows[2].inUse + ut\assertEquals rows[2].lastFetchedAt, 1700000000 -- surfaced from entry.lastFetchedAt + ut\assertNil rows[1].lastFetchedAt + + -- regression: a trust-only feed (no provenance) keeps inTrustedFeeds on its row, so the row round-trips + -- back through applyAction and Remove actually fires — previously the row dropped it and Remove no-op'd + buildRows_trustOnlyFeedRoundTripsRemove: (ut) -> + mgr, calls = makeManager! + rows = FeedManager.buildRows { + {url: "feed://t", provenance: {}, inTrustedFeeds: true, trustStatus: TrustStatus.TrustedUser} + } + ut\assertTrue rows[1].removable + ut\assertTrue rows[1].inTrustedFeeds + ut\assertTrue mgr\applyAction FeedAction.Remove, rows[1] + ut\assertEquals calls, {{"removeExtraFeed", "feed://t"}, {"untrust", "feed://t"}} + + _order: { + "availableActions_untrusted", "availableActions_trustedUser", "availableActions_trustedFeedsOnly" + "availableActions_bootstrapFeed" + "availableActions_trustedOfficial", "availableActions_blockedUserExact", "availableActions_blockedPrefix" + "availableActions_blockedAndUserListed", "getBrowserUrl_slug" + "applyAction_trust", "applyAction_block", "applyAction_removeClearsBothLists", "applyAction_unblock" + "applyAction_refusesGuarded", "applyAction_openBrowserNoOp" + "buildRows_assemblesAndSorts", "buildRows_trustOnlyFeedRoundTripsRemove" + } + } diff --git a/modules/l0/DependencyControl/test/FeedTrust.moon b/modules/l0/DependencyControl/test/FeedTrust.moon new file mode 100644 index 0000000..eea58cd --- /dev/null +++ b/modules/l0/DependencyControl/test/FeedTrust.moon @@ -0,0 +1,335 @@ +-- FeedTrust tests: the consolidated feed-trust model — official/user merge, trust queries, and mutations. +-- Called from test.moon as: (controls\requireTest "FeedTrust")! +() -> + FeedTrust = require "l0.DependencyControl.FeedTrust" + TrustStatus = FeedTrust.TrustStatus + {:makeSeededFeedTrust} = require "l0.DependencyControl.test.helpers.stub-helpers" + + -- A FeedTrust seeded with the official sets (so it never loads the live DepCtrl feed) over a stub config. + -- opts: officialTrusted (set), officialBlocked (list), extraFeeds, trustedFeeds, blockedFeeds, onSave. + make = (opts = {}) -> + makeSeededFeedTrust { + config: { + c: {feeds: {extraFeeds: opts.extraFeeds, trustedFeeds: opts.trustedFeeds, blockedFeeds: opts.blockedFeeds, fetchUntrustedFeeds: opts.fetchUntrustedFeeds}} + save: (=> opts.onSave! if opts.onSave) + } + official: {trusted: opts.officialTrusted or {}, blocked: opts.officialBlocked or {}} + } + + { + _description: "FeedTrust: official/user trust merge, trust queries, and config mutations." + + -- getOfficial* short-circuit when the official cache is present (loadOfficial returns it without + -- rebuilding): assertIs proves the same table comes back. + getOfficialTrustedFeeds_usesCacheWhenPresent: (ut) -> + cached = {trusted: {"feed://a": true}, blocked: {}} + ft = makeSeededFeedTrust {official: cached} + ut\assertTrue FeedTrust.getOfficialTrustedFeeds(ft)["feed://a"] + ut\assertIs ft.__official, cached + + getOfficialBlockedFeeds_usesCacheWhenPresent: (ut) -> + cached = {trusted: {}, blocked: {{url: "https://bad.example/"}}} + ft = makeSeededFeedTrust {official: cached} + ut\assertEquals FeedTrust.getOfficialBlockedFeeds(ft), {{url: "https://bad.example/"}} + ut\assertIs ft.__official, cached + + -- a failed official-feed load is not cached: a later call retries and caches once the feed loads + loadOfficial_retriesAfterFailure: (ut) -> + calls = 0 + fakeFeed = { + ensureLoaded: => + calls += 1 + calls > 1 -- fails first, succeeds thereafter + getKnownFeeds: => {"feed://known"} + data: {blockedFeeds: {}} + } + ft = makeSeededFeedTrust {feedLoader: {load: ((url, opts) => fakeFeed)}} + first = FeedTrust.getOfficialTrustedFeeds ft + ut\assertNil ft.__official -- failed load -> not cached + ut\assertNil first["feed://known"] -- fallback trusts only DepCtrl's own url + second = FeedTrust.getOfficialTrustedFeeds ft -- retries -> succeeds this time + ut\assertNotNil ft.__official -- cached now + ut\assertTrue second["feed://known"] + + -- getTrustedFeeds merges the official trusted set with the user's extraFeeds and trustedFeeds. + getTrustedFeeds_mergesOfficialAndUser: (ut) -> + ft = make officialTrusted: {"feed://official": true}, extraFeeds: {"feed://extra"}, trustedFeeds: {"feed://trusted"} + trusted = FeedTrust.getTrustedFeeds ft + ut\assertTrue trusted["feed://official"] + ut\assertTrue trusted["feed://extra"] + ut\assertTrue trusted["feed://trusted"] + + getTrustedFeeds_officialOnlyWhenNoUserFeeds: (ut) -> + ft = make officialTrusted: {"feed://official": true} + trusted = FeedTrust.getTrustedFeeds ft + ut\assertTrue trusted["feed://official"] + ut\assertNil trusted["feed://extra"] + + -- getBlockedFeeds: official entries first (tagged official), then the user's, each normalized to an object. + getBlockedFeeds_mergesOfficialThenUser: (ut) -> + ft = make officialBlocked: {{url: "https://bad.example/"}}, blockedFeeds: {{url: "https://evil.example/"}} + blocked = FeedTrust.getBlockedFeeds ft + ut\assertEquals #blocked, 2 + ut\assertEquals blocked[1].url, "https://bad.example/" + ut\assertTrue blocked[1].isOfficial + ut\assertEquals blocked[2].url, "https://evil.example/" + ut\assertFalse blocked[2].isOfficial + + getBlockedFeeds_officialOnlyWhenNoUserFeeds: (ut) -> + ft = make officialBlocked: {{url: "https://bad.example/"}} + blocked = FeedTrust.getBlockedFeeds ft + ut\assertEquals #blocked, 1 + ut\assertEquals blocked[1].url, "https://bad.example/" + ut\assertEquals blocked[1].matchMode, "prefix" + + -- isTrusted checks the merged set (exact match), guarding nil; isBlocked uses prefix matching. + isTrusted_checksMergedSet: (ut) -> + ft = make officialTrusted: {"feed://o": true}, trustedFeeds: {"feed://t"} + ut\assertTrue FeedTrust.isTrusted ft, "feed://o" + ut\assertTrue FeedTrust.isTrusted ft, "feed://t" + ut\assertFalse FeedTrust.isTrusted ft, "feed://x" + ut\assertFalse FeedTrust.isTrusted ft, nil + + isBlocked_prefixMatch: (ut) -> + ft = make officialBlocked: {{url: "https://bad.example/"}} + ut\assertTrue FeedTrust.isBlocked ft, "https://bad.example/a/b.json" + ut\assertFalse FeedTrust.isBlocked ft, "https://ok.example/" + + -- isUserTrusted is true only for the user's own lists (extraFeeds/trustedFeeds), never the official set. + isUserTrusted_userListsOnly: (ut) -> + ft = make officialTrusted: {"feed://o": true}, extraFeeds: {"feed://e"}, trustedFeeds: {"feed://t"} + ut\assertTrue FeedTrust.isUserTrusted ft, "feed://e" + ut\assertTrue FeedTrust.isUserTrusted ft, "feed://t" + ut\assertFalse FeedTrust.isUserTrusted ft, "feed://o" -- official, not one of the user's lists + ut\assertFalse FeedTrust.isUserTrusted ft, "feed://x" + ut\assertFalse FeedTrust.isUserTrusted ft, nil + + -- isOfficiallyTrusted is the complement: true only for the official set, never the user's own lists. + isOfficiallyTrusted_officialSetOnly: (ut) -> + ft = make officialTrusted: {"feed://o": true}, extraFeeds: {"feed://e"}, trustedFeeds: {"feed://t"} + ut\assertTrue FeedTrust.isOfficiallyTrusted ft, "feed://o" + ut\assertFalse FeedTrust.isOfficiallyTrusted ft, "feed://e" -- a user list, not the official set + ut\assertFalse FeedTrust.isOfficiallyTrusted ft, "feed://t" + ut\assertFalse FeedTrust.isOfficiallyTrusted ft, nil + + -- getTrustStatus classifies official and user trust independently: official-only, user-only, both, or neither. + getTrustStatus_classifiesTrust: (ut) -> + ft = make officialTrusted: {"feed://o": true, "feed://both": true}, extraFeeds: {"feed://u"}, trustedFeeds: {"feed://both"} + ut\assertEquals FeedTrust.getTrustStatus(ft, "feed://o"), TrustStatus.TrustedOfficial + ut\assertEquals FeedTrust.getTrustStatus(ft, "feed://u"), TrustStatus.TrustedUser + ut\assertEquals FeedTrust.getTrustStatus(ft, "feed://both"), TrustStatus.TrustedBoth + ut\assertEquals FeedTrust.getTrustStatus(ft, "feed://x"), TrustStatus.Untrusted + + -- a block overrides any trust, and getTrustStatus returns the matching entry as its second value. + getTrustStatus_blockOverridesAndReturnsEntry: (ut) -> + ft = make officialTrusted: {"feed://bad": true}, officialBlocked: {{url: "feed://bad", reason: "malware"}} + status, entry = FeedTrust.getTrustStatus ft, "feed://bad" + ut\assertEquals status, TrustStatus.Blocked + ut\assertEquals entry.reason, "malware" + ut\assertTrue entry.isOfficial + + -- trust/block append to the user config, persist, and invalidate the cached merged set so the new + -- feed is immediately reflected. + trust_appendsPersistsAndInvalidates: (ut) -> + saved = {} + ft = make trustedFeeds: {}, onSave: -> saved[1] = true + FeedTrust.getTrustedFeeds ft -- prime the cache + FeedTrust.trust ft, "feed://new" + ut\assertEquals ft.config.c.feeds.trustedFeeds[1], "feed://new" + ut\assertTrue saved[1] + ut\assertTrue FeedTrust.isTrusted ft, "feed://new" + + block_appendsPersistsAndInvalidates: (ut) -> + saved = {} + ft = make blockedFeeds: {}, onSave: -> saved[1] = true + FeedTrust.getBlockedFeeds ft -- prime the cache + FeedTrust.block ft, "https://bad/" + ut\assertEquals ft.config.c.feeds.blockedFeeds[1], {url: "https://bad/", matchMode: "prefix"} + ut\assertTrue saved[1] + ut\assertTrue FeedTrust.isBlocked ft, "https://bad/x" + + -- trust/block/addExtraFeed ignore an exact duplicate: no second entry, no save, return false. + trust_ignoresDuplicate: (ut) -> + saved = {} + ft = make trustedFeeds: {"feed://a"}, onSave: -> saved[1] = true + ut\assertFalse FeedTrust.trust ft, "feed://a" + ut\assertEquals ft.config.c.feeds.trustedFeeds, {"feed://a"} + ut\assertNil saved[1] + + block_ignoresDuplicate: (ut) -> + saved = {} + ft = make blockedFeeds: {{url: "https://bad/", matchMode: "prefix"}}, onSave: -> saved[1] = true + ut\assertFalse FeedTrust.block ft, "https://bad/" + ut\assertEquals ft.config.c.feeds.blockedFeeds, {{url: "https://bad/", matchMode: "prefix"}} + ut\assertNil saved[1] + + -- untrust removes only the user's trustedFeeds entry, persists, and invalidates the cached set. + untrust_removesPersistsAndInvalidates: (ut) -> + saved = {} + ft = make trustedFeeds: {"feed://a", "feed://b"}, onSave: -> saved[1] = true + FeedTrust.getTrustedFeeds ft -- prime the cache + ut\assertTrue FeedTrust.untrust ft, "feed://a" + ut\assertEquals ft.config.c.feeds.trustedFeeds, {"feed://b"} + ut\assertTrue saved[1] + ut\assertFalse FeedTrust.isTrusted ft, "feed://a" + + untrust_returnsFalseWhenAbsent: (ut) -> + saved = {} + ft = make trustedFeeds: {"feed://a"}, onSave: -> saved[1] = true + ut\assertFalse FeedTrust.untrust ft, "feed://missing" + ut\assertNil saved[1] + ut\assertEquals ft.config.c.feeds.trustedFeeds, {"feed://a"} + + -- untrust can't remove a feed trusted only through the official set (block it to override). + untrust_leavesOfficialTrusted: (ut) -> + ft = make officialTrusted: {"feed://o": true}, trustedFeeds: {} + ut\assertFalse FeedTrust.untrust ft, "feed://o" + ut\assertTrue FeedTrust.isTrusted ft, "feed://o" + + unblock_removesPersistsAndInvalidates: (ut) -> + saved = {} + ft = make blockedFeeds: {{url: "https://bad/", matchMode: "prefix"}, {url: "https://evil/", matchMode: "prefix"}}, onSave: -> saved[1] = true + FeedTrust.getBlockedFeeds ft -- prime the cache + ut\assertTrue FeedTrust.unblock ft, "https://bad/" + ut\assertEquals ft.config.c.feeds.blockedFeeds, {{url: "https://evil/", matchMode: "prefix"}} + ut\assertTrue saved[1] + ut\assertFalse FeedTrust.isBlocked ft, "https://bad/x" + + unblock_leavesOfficialBlocked: (ut) -> + ft = make officialBlocked: {{url: "https://bad/"}}, blockedFeeds: {} + ut\assertFalse FeedTrust.unblock ft, "https://bad/" + ut\assertTrue FeedTrust.isBlocked ft, "https://bad/x" + + -- addExtraFeed adds a trusted discovery root, persists, and invalidates the merged trusted set. + addExtraFeed_addsPersistsAndInvalidates: (ut) -> + saved = {} + ft = make extraFeeds: {}, onSave: -> saved[1] = true + FeedTrust.getTrustedFeeds ft -- prime the cache + ut\assertTrue FeedTrust.addExtraFeed ft, "feed://extra" + ut\assertEquals ft.config.c.feeds.extraFeeds, {"feed://extra"} + ut\assertTrue saved[1] + ut\assertTrue FeedTrust.isTrusted ft, "feed://extra" + + removeExtraFeed_removesAndInvalidates: (ut) -> + ft = make extraFeeds: {"feed://x"} + FeedTrust.getTrustedFeeds ft -- prime the cache + ut\assertTrue FeedTrust.removeExtraFeed ft, "feed://x" + ut\assertEquals ft.config.c.feeds.extraFeeds, {} + ut\assertFalse FeedTrust.isTrusted ft, "feed://x" + + -- an exact block matches only the exact URL (case-insensitively), not sub-paths. + block_exactMatchModeMatchesExactUrlOnly: (ut) -> + ft = make blockedFeeds: {} + FeedTrust.block ft, "https://x.example/feed.json", {matchMode: "exact", reason: "typosquat"} + ut\assertTrue FeedTrust.isBlocked ft, "https://X.example/Feed.json" + ut\assertFalse FeedTrust.isBlocked ft, "https://x.example/feed.json/pkg" + + -- every block is stored as a `{url, matchMode, reason?}` object (blocks have no bare-string form). + block_alwaysStoresObject: (ut) -> + ft = make blockedFeeds: {} + FeedTrust.block ft, "https://a/", {reason: "bad"} + FeedTrust.block ft, "https://b/" + ut\assertEquals ft.config.c.feeds.blockedFeeds[1], {url: "https://a/", matchMode: "prefix", reason: "bad"} + ut\assertEquals ft.config.c.feeds.blockedFeeds[2], {url: "https://b/", matchMode: "prefix"} + + -- block dedups by url + match mode: the same prefix is ignored, but the same url at a different mode is allowed. + block_dedupsByUrlAndMatchMode: (ut) -> + saved = {} + ft = make blockedFeeds: {{url: "https://d/", matchMode: "prefix"}}, onSave: -> saved[1] = true + ut\assertFalse FeedTrust.block ft, "https://d/" + ut\assertNil saved[1] + ut\assertTrue FeedTrust.block ft, "https://d/", {matchMode: "exact"} + + -- a url differing only in case is a duplicate (matching is case-insensitive), and a bogus mode defaults to prefix + block_dedupsCaseInsensitivelyAndValidatesMode: (ut) -> + ft = make blockedFeeds: {{url: "https://Bad/", matchMode: "prefix"}} + ut\assertFalse FeedTrust.block ft, "https://bad/" -- case-only difference -> duplicate + ft2 = make blockedFeeds: {} + FeedTrust.block ft2, "https://x/", {matchMode: "bogus"} + ut\assertEquals ft2.config.c.feeds.blockedFeeds[1].matchMode, "prefix" -- unknown mode -> prefix + + -- getBlockingEntry surfaces the matching entry's reason and marks official blocks. + getBlockingEntry_surfacesReasonAndOfficial: (ut) -> + ft = make officialBlocked: {{url: "https://bad/", reason: "malware"}} + entry = FeedTrust.getBlockingEntry ft, "https://bad/pkg.json" + ut\assertEquals entry.reason, "malware" + ut\assertEquals entry.matchMode, "prefix" + ut\assertTrue entry.isOfficial + ut\assertNil FeedTrust.getBlockingEntry ft, "https://ok/" + + -- urlMatchesPrefix: case-insensitive, prefix-based block-list matching (the evasion-resistant primitive). + urlMatchesPrefix_exactAndCaseInsensitive: (ut) -> + ut\assertTrue FeedTrust\urlMatchesPrefix "https://example.com/feed.json", {"https://example.com/feed.json"} + ut\assertTrue FeedTrust\urlMatchesPrefix "https://Example.COM/Feed.json", {"https://example.com/feed.json"} + + urlMatchesPrefix_hostPrefixBlocksEverythingUnder: (ut) -> + ut\assertTrue FeedTrust\urlMatchesPrefix "https://example.com/a/b.json", {"https://example.com/"} + + urlMatchesPrefix_noMatch: (ut) -> + ut\assertFalse FeedTrust\urlMatchesPrefix "https://other.com/feed.json", {"https://example.com/"} + + -- guards: nil url, no entries, and an empty entry (which must not match everything) + urlMatchesPrefix_guards: (ut) -> + ut\assertFalse FeedTrust\urlMatchesPrefix nil, {"https://example.com/"} + ut\assertFalse FeedTrust\urlMatchesPrefix "https://example.com/x", {} + ut\assertFalse FeedTrust\urlMatchesPrefix "https://example.com/x", {""} + + -- the fetch gate: block always denies, trust always allows, untrusted follows fetchUntrustedFeeds + getFetchDecision_blockDeniesEvenUnderAlways: (ut) -> + ft = make blockedFeeds: {{url: "feed://bad", matchMode: "exact"}}, fetchUntrustedFeeds: "always" + ut\assertEquals FeedTrust.getFetchDecision(ft, "feed://bad"), FeedTrust.FetchDecision.Deny + + getFetchDecision_trustAllowsEvenUnderNever: (ut) -> + ft = make trustedFeeds: {"feed://t"}, fetchUntrustedFeeds: "never" + ut\assertEquals FeedTrust.getFetchDecision(ft, "feed://t"), FeedTrust.FetchDecision.Allow + + getFetchDecision_untrustedFollowsPolicy: (ut) -> + ut\assertEquals FeedTrust.getFetchDecision(make(fetchUntrustedFeeds: "always"), "feed://u"), FeedTrust.FetchDecision.Allow + ut\assertEquals FeedTrust.getFetchDecision(make(fetchUntrustedFeeds: "never"), "feed://u"), FeedTrust.FetchDecision.Deny + ut\assertEquals FeedTrust.getFetchDecision(make(fetchUntrustedFeeds: "prompt"), "feed://u"), FeedTrust.FetchDecision.Prompt + + -- an unrecognized/corrupted policy value fails closed (deny) rather than silently allowing untrusted fetches + getFetchDecision_unknownPolicyFailsClosed: (ut) -> + ut\assertEquals FeedTrust.getFetchDecision(make(fetchUntrustedFeeds: "bogus"), "feed://u"), FeedTrust.FetchDecision.Deny + + -- shouldFetch resolves Allow/Deny to booleans; a prompt policy with no prompter denies (headless-safe) + shouldFetch_resolvesAllowDenyAndHeadlessPrompt: (ut) -> + ut\assertTrue FeedTrust.shouldFetch make(trustedFeeds: {"feed://t"}), "feed://t" + ut\assertFalse FeedTrust.shouldFetch make(fetchUntrustedFeeds: "never"), "feed://u" + ut\assertFalse FeedTrust.shouldFetch make(fetchUntrustedFeeds: "prompt"), "feed://u" + + -- under the prompt policy the prompter is consulted, and its answer is remembered for the session + shouldFetch_promptConsultsPrompterOnceAndCaches: (ut) -> + calls = 0 + prompter = (url, feedTrust) -> + calls += 1 + true + ft = make fetchUntrustedFeeds: "prompt" + FeedTrust.setPrompter ft, prompter + ut\assertTrue FeedTrust.shouldFetch ft, "feed://u" + ut\assertTrue FeedTrust.shouldFetch ft, "feed://u" + ut\assertEquals calls, 1 + + _order: { + "getOfficialTrustedFeeds_usesCacheWhenPresent", "getOfficialBlockedFeeds_usesCacheWhenPresent" + "loadOfficial_retriesAfterFailure" + "getTrustedFeeds_mergesOfficialAndUser", "getTrustedFeeds_officialOnlyWhenNoUserFeeds" + "getBlockedFeeds_mergesOfficialThenUser", "getBlockedFeeds_officialOnlyWhenNoUserFeeds" + "isTrusted_checksMergedSet", "isBlocked_prefixMatch", "isUserTrusted_userListsOnly" + "isOfficiallyTrusted_officialSetOnly" + "getTrustStatus_classifiesTrust", "getTrustStatus_blockOverridesAndReturnsEntry" + "trust_appendsPersistsAndInvalidates", "block_appendsPersistsAndInvalidates" + "trust_ignoresDuplicate", "block_ignoresDuplicate", "block_dedupsCaseInsensitivelyAndValidatesMode" + "untrust_removesPersistsAndInvalidates", "untrust_returnsFalseWhenAbsent", "untrust_leavesOfficialTrusted" + "unblock_removesPersistsAndInvalidates", "unblock_leavesOfficialBlocked" + "addExtraFeed_addsPersistsAndInvalidates", "removeExtraFeed_removesAndInvalidates" + "block_exactMatchModeMatchesExactUrlOnly", "block_alwaysStoresObject" + "block_dedupsByUrlAndMatchMode", "getBlockingEntry_surfacesReasonAndOfficial" + "urlMatchesPrefix_exactAndCaseInsensitive", "urlMatchesPrefix_hostPrefixBlocksEverythingUnder" + "urlMatchesPrefix_noMatch", "urlMatchesPrefix_guards" + "getFetchDecision_blockDeniesEvenUnderAlways", "getFetchDecision_trustAllowsEvenUnderNever" + "getFetchDecision_untrustedFollowsPolicy", "getFetchDecision_unknownPolicyFailsClosed", + "shouldFetch_resolvesAllowDenyAndHeadlessPrompt" + "shouldFetch_promptConsultsPrompterOnceAndCaches" + } + } diff --git a/modules/l0/DependencyControl/test/FileCache.moon b/modules/l0/DependencyControl/test/FileCache.moon new file mode 100644 index 0000000..72f514c --- /dev/null +++ b/modules/l0/DependencyControl/test/FileCache.moon @@ -0,0 +1,188 @@ +-- FileCache tests: key-addressed persistent JSON snapshots indexed by a deterministic meta file, with a +-- freshness window, human-readable snapshot names, stale-fallback lookup, and retention trimming. Instances +-- live under <basePath>/<namespace>/<name>. Uses a temp base + an injected clock; no network. +-- Called from test.moon as: (require "…test.FileCache") basePath +(basePath) -> + FileCache = require "l0.DependencyControl.FileCache" + FileOps = require "l0.DependencyControl.FileOps" + + -- a fresh cache in its own base subdir with a controllable clock; returns (cache, clock) + makeCache = (name, opts = {}) -> + clock = {t: opts.t or 1000} + opts.now = -> clock.t + FileCache(FileOps.joinPath(basePath, "filecache", name), "testns", "test", opts), clock + + readFile = FileOps.readFile + + { + _description: "FileCache: persistent key-addressed JSON snapshots with freshness, readable names, and trimming." + + -- a stored blob round-trips: readable snapshot on disk, index points at it, content preserved + put_roundTrip: (ut) -> + cache = makeCache "roundtrip" + meta = cache\put "https://example.com/DependencyControl.json", '{"name":"DepCtrl"}', "DependencyControl" + ut\assertNotNil meta + ut\assertEquals meta.key, "https://example.com/DependencyControl.json" + ut\assertMatches meta.latestFile, "^%x%x%x%x%x%x%x%-DependencyControl%-%d+T%d+Z%-%x%x%x%x%.json$" + + path, gotMeta = cache\getFile "https://example.com/DependencyControl.json" + ut\assertNotNil path + ut\assertEquals readFile(path), '{"name":"DepCtrl"}' + ut\assertEquals gotMeta.latestFile, meta.latestFile + + -- the default (real) clock path works end to end — put/get use os.time/os.date, not an injected stub + put_worksWithDefaultClock: (ut) -> + cache = FileCache FileOps.joinPath(basePath, "filecache", "defaultclock"), "testns", "test" + meta = cache\put "u://real", '{"name":"Real"}', "Real" + ut\assertNotNil meta + ut\assertEquals readFile(cache\getFile "u://real"), '{"name":"Real"}' + + -- an unknown key is a miss + getFile_uncached: (ut) -> + cache = makeCache "uncached" + ut\assertNil (cache\getFile "https://example.com/none.json") + + -- freshness is bounded by maxAge, measured from cache time + isFresh_window: (ut) -> + cache, clock = makeCache "fresh", {maxAge: 100, t: 1000} + meta = cache\put "u://f", "{}", "f" + ut\assertTrue cache\isFresh meta + clock.t = 1099 + ut\assertTrue cache\isFresh meta + clock.t = 1100 + ut\assertFalse cache\isFresh meta + + -- a second put repoints the index at the newer snapshot + put_updatesLatest: (ut) -> + cache, clock = makeCache "update", {t: 1000} + first = cache\put "u://f", '{"v":1}', "f" + clock.t = 1002 + second = cache\put "u://f", '{"v":2}', "f" + ut\assertNotEquals first.latestFile, second.latestFile + path = cache\getFile "u://f" + ut\assertEquals readFile(path), '{"v":2}' + + -- a stale entry is still resolvable, so callers can fall back to it (with a warning) when offline + getFile_staleStillResolves: (ut) -> + cache, clock = makeCache "stale", {maxAge: 100, t: 1000} + cache\put "u://f", '{"cached":true}', "f" + clock.t = 5000 + path, meta = cache\getFile "u://f" + ut\assertNotNil path + ut\assertFalse cache\isFresh meta + ut\assertEquals readFile(path), '{"cached":true}' + + -- filesystem-hostile characters in the label are replaced (label follows the key slug) + put_sanitizesLabel: (ut) -> + cache = makeCache "sanitize" + meta = cache\put "u://f", "{}", "My Feed/../x" + ut\assertMatches meta.latestFile, "^%x%x%x%x%x%x%x%-My_Feed_.._" + + -- .get reuses one instance per resolved directory, and constructs distinct ones for a different name + get_sharesInstancePerDir: (ut) -> + base = FileOps.joinPath basePath, "filecache", "shared" + a1 = FileCache.get base, "ns", "one" + a2 = FileCache.get base, "ns", "one" + b = FileCache.get base, "ns", "two" + ut\assertTrue a1 == a2 -- same base/namespace/name → shared instance + ut\assertFalse a1 == b -- different name → distinct instance + + -- expiry is fixed at write time, so bumping the instance's maxAge afterwards can't retroactively prolong it + put_expiryFixedAtWriteTime: (ut) -> + cache, clock = makeCache "fixed-expiry", {maxAge: 100, t: 1000} + meta = cache\put "u://f", "{}", "f" -- expiresAt fixed at 1000 + 100 = 1100 + cache.maxAge = 100000 -- a later, longer default lifetime + clock.t = 1200 + ut\assertFalse cache\isFresh meta -- still stale: honors the baked 1100, not the new maxAge + + -- a per-resource expiresAfter on put overrides the cache's default lifetime for that one entry + put_perResourceExpiry: (ut) -> + cache, clock = makeCache "per-resource", {maxAge: 100, t: 1000} + short = cache\put "u://short", "{}", "s", 10 -- expiresAt 1010 + long = cache\put "u://long", "{}", "l", 5000 -- expiresAt 6000 + clock.t = 1050 + ut\assertFalse cache\isFresh short -- 1050 >= 1010 + ut\assertTrue cache\isFresh long -- 1050 < 6000 + + -- trimming caps retained snapshots but never deletes an entry's current one + trim_keepsLatestOverCap: (ut) -> + cache, clock = makeCache "trim", {maxFiles: 2, t: 1000} + first = cache\put "u://f", '{"v":1}', "f" + clock.t = 1001 + cache\put "u://f", '{"v":2}', "f" + clock.t = 1002 + third = cache\put "u://f", '{"v":3}', "f" + + -- the oldest, unprotected snapshot is gone; the newest (the index's target) survives + ut\assertFalsy FileOps.attributes FileOps.joinPath(cache.cacheDir, first.latestFile), "mode" + ut\assertEquals "file", FileOps.attributes FileOps.joinPath(cache.cacheDir, third.latestFile), "mode" + path = cache\getFile "u://f" + ut\assertEquals readFile(path), '{"v":3}' + + -- get materializes the snapshot through the codec and memoizes it: a second get returns the same object + get_materializesAndMemoizes: (ut) -> + cache = makeCache "get-memo", {deserialize: (content) -> {:content}} + cache\put "u://f", '{"v":1}', "f" + v1, _, fresh = cache\get "u://f" + ut\assertEquals v1.content, '{"v":1}' + ut\assertTrue fresh + v2 = cache\get "u://f" + ut\assertIs v1, v2 -- same object → served from L1, not re-deserialized + + -- a memo is keyed to its snapshot's cache time, so another writer's newer put supersedes it: get re-reads L2 + get_memoSupersededByNewerSnapshot: (ut) -> + dir = FileOps.joinPath basePath, "filecache", "get-super" + codec = (content) -> {:content} + a = FileCache dir, "ns", "s", {deserialize: codec, now: -> 1000} + a\put "u://f", '{"v":1}', "f" + a\get "u://f" -- a's L1 memoizes v1 @ cachedAt 1000 + b = FileCache dir, "ns", "s", {deserialize: codec, now: -> 1002} + b\put "u://f", '{"v":2}', "f" -- L2 snapshot is now v2 @ cachedAt 1002 + value = a\get "u://f" -- a's memo no longer matches the on-disk cachedAt → re-reads L2 + ut\assertEquals value.content, '{"v":2}' + + -- get reports staleness without withholding the value, so callers can use it as an offline fallback + get_staleReturnsValueWithFreshFalse: (ut) -> + cache, clock = makeCache "get-stale", {maxAge: 100, t: 1000, deserialize: (content) -> {:content}} + cache\put "u://f", '{"cached":true}', "f" + clock.t = 1200 + value, meta, fresh = cache\get "u://f" + ut\assertEquals value.content, '{"cached":true}' + ut\assertFalse fresh + ut\assertNotNil meta + + -- an uncached key is a plain miss + get_missReturnsNil: (ut) -> + cache = makeCache "get-miss", {deserialize: (content) -> {:content}} + ut\assertNil (cache\get "u://none") + + -- soft expireAll marks entries cached before the cut-off stale but keeps the snapshot; a later put refreshes + expireAll_marksOlderStaleKeepingSnapshot: (ut) -> + cache, clock = makeCache "expire-soft", {maxAge: 100000, t: 1000} + meta = cache\put "u://f", '{"v":1}', "f" + ut\assertTrue cache\isFresh meta -- within the (large) window + cache\expireAll 1500 -- everything cached before 1500 is now stale + ut\assertFalse cache\isFresh meta + ut\assertNotNil (cache\getFile "u://f") -- snapshot retained as an offline fallback + clock.t = 1600 + refreshed = cache\put "u://f", '{"v":2}', "f" -- re-put after the cut-off + ut\assertTrue cache\isFresh refreshed -- fresh again + + -- purging expireAll deletes the affected entries' L1 memo, snapshot, and index, so the key becomes a miss + expireAll_purgeDeletesEntries: (ut) -> + cache = makeCache "expire-purge", {t: 1000, deserialize: (content) -> {:content}} + meta = cache\put "u://f", '{"v":1}', "f" + cache\get "u://f" -- prime the L1 memo + cache\expireAll 2000, true -- purge everything cached before 2000 + ut\assertNil (cache\get "u://f") -- memo dropped and L2 gone → full miss + ut\assertFalsy FileOps.attributes FileOps.joinPath(cache.cacheDir, meta.latestFile), "mode" + + _order: { + "put_roundTrip", "put_worksWithDefaultClock", "getFile_uncached", "isFresh_window", "put_updatesLatest" + "getFile_staleStillResolves", "put_sanitizesLabel" + "get_sharesInstancePerDir", "put_expiryFixedAtWriteTime", "put_perResourceExpiry", "trim_keepsLatestOverCap" + "get_materializesAndMemoizes", "get_memoSupersededByNewerSnapshot" + "get_staleReturnsValueWithFreshFalse", "get_missReturnsNil" + "expireAll_marksOlderStaleKeepingSnapshot", "expireAll_purgeDeletesEntries" + } + } diff --git a/modules/l0/DependencyControl/test/FileLock.moon b/modules/l0/DependencyControl/test/FileLock.moon new file mode 100644 index 0000000..f40d473 --- /dev/null +++ b/modules/l0/DependencyControl/test/FileLock.moon @@ -0,0 +1,42 @@ +-- FileLock tests: the cross-process advisory file-lock primitive, exercised against real OS lock files +-- (LockFileEx on Windows, flock on POSIX). Skipped where the lock FFI is unavailable. +-- Called from test.moon as: (controls\requireTest "FileLock")! +() -> + FileLock = require "l0.DependencyControl.FileLock" + + -- a fresh lock-file path per call (?temp is created by the test runner) + tmpPath = -> aegisub.decode_path "?temp/depctrl_filelock_#{'%08X'\format math.random 0, 16^8-1}.lock" + + { + _description: "FileLock: the cross-process advisory file lock, against real OS lock files." + -- the lock FFI isn't available on every platform/build; skip rather than fail there + _condition: -> FileLock.isAvailable, "no file-lock FFI on this platform/build" + + -- a fresh lock opens, acquires, and releases + acquiresAndReleases: (ut) -> + lock = FileLock tmpPath! + ut\assertTrue lock.isOpen + ut\assertTrue lock\tryLock! + ut\assertTrue lock\unlock! + + -- two independent locks on the same file contend (the OS lock is per-file/open, not reentrant per + -- handle): the second can't acquire while the first holds it, and can once it's released + secondHolderContends: (ut) -> + path = tmpPath! + a, b = FileLock(path), FileLock(path) + ut\assertTrue a\tryLock! + ut\assertFalse b\tryLock! + a\unlock! + ut\assertTrue b\tryLock! + b\unlock! + + -- the same holder can re-acquire after releasing + reacquiresAfterUnlock: (ut) -> + lock = FileLock tmpPath! + ut\assertTrue lock\tryLock! + lock\unlock! + ut\assertTrue lock\tryLock! + lock\unlock! + + _order: {"acquiresAndReleases", "secondHolderContends", "reacquiresAfterUnlock"} + } diff --git a/modules/l0/DependencyControl/test/FileOps.moon b/modules/l0/DependencyControl/test/FileOps.moon new file mode 100644 index 0000000..001a421 --- /dev/null +++ b/modules/l0/DependencyControl/test/FileOps.moon @@ -0,0 +1,497 @@ +-- FileOps tests: path validation and filesystem utilities. +-- 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 "/" + + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + + -- 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 + + { + _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 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<invalid>.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" + + -- an empty or separator-only segment contributes nothing and must not truncate later segments + joinPath_skipsEmptySegments: (ut) -> + ut\assertEquals FileOps.joinPath("path", "", "file.txt"), "path#{pathSep}file.txt" + ut\assertEquals FileOps.joinPath("path", {}, "file.txt"), "path#{pathSep}file.txt" + ut\assertEquals FileOps.joinPath("a", "b/c", "d"), "a#{pathSep}b#{pathSep}c#{pathSep}d" + + -- 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 + + -- Aegisub's lfs.mkdir returns nothing on success; only an explicit error or a directory + -- that is still missing counts as failure + mkdir_acceptsSilentLfsSuccess: (ut) -> + created = false + (ut\stub lfs, "attributes")\calls (path, key) -> created and "directory" or nil + (ut\stub lfs, "mkdir")\calls (path) -> + created = true + nil + result, dir = FileOps.mkdir {basePath, "silent-new"} + ut\assertTrue result + ut\assertString dir + + mkdir_silentLfsFailure: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> nil + (ut\stub lfs, "mkdir")\calls (path) -> nil + result, err = FileOps.mkdir {basePath, "silent-fail"} + ut\assertNil result + ut\assertString err + + rmdir_acceptsSilentLfsSuccess: (ut) -> + removed = false + (ut\stub lfs, "attributes")\calls (path, key) -> + return nil if removed + "directory" + (ut\stub lfs, "rmdir")\calls (path) -> + removed = true + nil + result, err = FileOps.rmdir {basePath, "silent-rm"}, false + ut\assertTrue result + ut\assertNil err + + -- 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 + + -- a directory is removed non-recursively unless recurse is passed, so a stray directory path can't + -- silently delete a whole tree (the recurse flag forwarded to rmdir must be false by default) + remove_dirNonRecursiveByDefault: (ut) -> + recurseArgs = {} + (ut\stub lfs, "attributes")\calls (path, key) -> "directory" + (ut\stub FileOps, "rmdir")\calls (path, recurse) -> recurseArgs[#recurseArgs + 1] = recurse; true + FileOps.remove FileOps.joinPath basePath, "d" + FileOps.remove FileOps.joinPath(basePath, "d"), true + ut\assertEquals recurseArgs, {false, true} + + -- 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 + + -- listFilesRecursive + + listFilesRecursive_collectsNestedFiles: (ut) -> + root = FileOps.joinPath basePath, "walk" + FileOps.mkdir FileOps.joinPath(root, "sub", "subsub"), false, true + FileOps.writeFile FileOps.joinPath(root, "a.txt"), "a", true + FileOps.writeFile FileOps.joinPath(root, "sub", "b.txt"), "b", true + FileOps.writeFile FileOps.joinPath(root, "sub", "subsub", "c.txt"), "c", true + files = FileOps.listFilesRecursive root + ut\assertTable files + ut\assertEquals #files, 3 + found = {file\match("[^/\\]+$"), true for file in *files} + ut\assertTrue found["a.txt"] + ut\assertTrue found["b.txt"] + ut\assertTrue found["c.txt"] + + listFilesRecursive_notDirectory: (ut) -> + filePath = FileOps.joinPath basePath, "walk-file.txt" + FileOps.writeFile filePath, "x", true + result, err = FileOps.listFilesRecursive filePath + ut\assertNil result + ut\assertString err + + _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", + "mkdir_acceptsSilentLfsSuccess", "mkdir_silentLfsFailure", "rmdir_acceptsSilentLfsSuccess", + "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", "remove_dirNonRecursiveByDefault", + "validateFullPath_withBasePath", + "getPathRoot_windowsPath", "getPathRoot_posixPath", "getPathRoot_relative", + "joinPath_segmentsArray", "joinPath_segmentsVarargs", "joinPath_segmentsMixed", + "joinPath_skipsEmptySegments", "joinPath_resolvesDotDot", "joinPath_invalidSegment", + "exists_fileFound", "exists_notFound", "exists_wrongType", "exists_noTypeCheck", + "listDir_success", "listDir_notDirectory", + "listFilesRecursive_collectsNestedFiles", "listFilesRecursive_notDirectory" + } + } diff --git a/modules/l0/DependencyControl/test/Finalizer.moon b/modules/l0/DependencyControl/test/Finalizer.moon new file mode 100644 index 0000000..d98eb9b --- /dev/null +++ b/modules/l0/DependencyControl/test/Finalizer.moon @@ -0,0 +1,53 @@ +-- Finalizer tests: runs a cleanup action when an anchored value is garbage-collected. +-- Called from test.moon as: (controls\requireTest "Finalizer")! +-> + Finalizer = require "l0.DependencyControl.Finalizer" + + -- Calls `alloc flag` (which builds the finalizer-anchored value and must not return it), then forces GC + -- until the flag flips. `alloc` runs in its own call frame, which LuaJIT reliably reclaims on return, so + -- its allocation becomes unreachable; a value held by a local in the test body could keep its register + -- slot live across the collection loop and never be collected. + collectAfter = (alloc) -> + flag = {done: false} + alloc flag + for _ = 1, 50 + collectgarbage "collect" + break if flag.done + flag.done + + { + _description: "Finalizer: runs a cleanup action when an anchored value is garbage-collected." + + -- create: the cleanup runs when the returned finalizer is collected + create_runsCleanupOnGC: (ut) -> + alloc = (flag) -> + Finalizer.create -> flag.done = true + return + ut\assertTrue collectAfter alloc + + -- create: an error raised by the cleanup is swallowed, so it can't escape during collection + create_swallowsCleanupErrors: (ut) -> + alloc = (flag) -> + Finalizer.create -> + flag.done = true + error "cleanup boom" + return + ut\assertTrue collectAfter alloc + + -- guard: pins the finalizer to the anchor, so the cleanup runs when the anchor is collected + guard_runsCleanupWhenAnchorCollected: (ut) -> + alloc = (flag) -> + Finalizer.guard {}, -> flag.done = true + return + ut\assertTrue collectAfter alloc + + -- guard: returns the anchor for chaining + guard_returnsAnchor: (ut) -> + anchor = {} + ut\assertEquals Finalizer.guard(anchor, ->), anchor + + _order: { + "create_runsCleanupOnGC", "create_swallowsCleanupErrors", + "guard_runsCleanupWhenAnchorCollected", "guard_returnsAnchor" + } + } 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/Host.moon b/modules/l0/DependencyControl/test/Host.moon new file mode 100644 index 0000000..e0bf21b --- /dev/null +++ b/modules/l0/DependencyControl/test/Host.moon @@ -0,0 +1,132 @@ +-- Host tests: URL host extraction, IPv4/IPv6 range classification, inet_aton literal parsing, and the +-- instance layer (resolution via an injected resolver, cached, plus fromUrl). Called from test.moon as: +-- (controls\requireTest "Host")! +() -> + Host = require "l0.DependencyControl.Host" + + -- A resolver over a fixed {host -> {address, ...}} map; an unknown host resolves to nil (lookup failed). + resolverOver = (map) -> (host) -> map[host] + + { + _description: "Host: SSRF address classification — URL parsing, IP ranges, inet_aton literals, resolution." + + -- isPrivateAddress: IPv4 non-public ranges + isPrivateAddress_ipv4Private: (ut) -> + ut\assertTrue Host.isPrivateAddress {127, 0, 0, 1} + ut\assertTrue Host.isPrivateAddress {10, 0, 0, 5} + ut\assertTrue Host.isPrivateAddress {192, 168, 1, 1} + ut\assertTrue Host.isPrivateAddress {172, 16, 0, 1} + ut\assertTrue Host.isPrivateAddress {172, 31, 255, 255} + ut\assertTrue Host.isPrivateAddress {169, 254, 169, 254} -- cloud metadata + ut\assertTrue Host.isPrivateAddress {100, 64, 0, 1} -- CGNAT + ut\assertTrue Host.isPrivateAddress {0, 0, 0, 0} + ut\assertTrue Host.isPrivateAddress {255, 255, 255, 255} + + -- isPrivateAddress: IPv4 public ranges (incl. just outside the private blocks) + isPrivateAddress_ipv4Public: (ut) -> + ut\assertFalse Host.isPrivateAddress {8, 8, 8, 8} + ut\assertFalse Host.isPrivateAddress {1, 1, 1, 1} + ut\assertFalse Host.isPrivateAddress {172, 15, 0, 1} -- just below 172.16/12 + ut\assertFalse Host.isPrivateAddress {172, 32, 0, 1} -- just above 172.16/12 + ut\assertFalse Host.isPrivateAddress {100, 128, 0, 1} -- just above 100.64/10 + ut\assertFalse Host.isPrivateAddress {192, 167, 1, 1} + + -- isPrivateAddress: IPv6 ranges, including IPv4-mapped + isPrivateAddress_ipv6: (ut) -> + loopback = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + unspecified = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + linkLocal = {0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + uniqueLocal = {0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + mappedPrivate = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1} + mappedPublic = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8} + publicV6 = {0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + ut\assertTrue Host.isPrivateAddress loopback + ut\assertTrue Host.isPrivateAddress unspecified + ut\assertTrue Host.isPrivateAddress linkLocal + ut\assertTrue Host.isPrivateAddress uniqueLocal + ut\assertTrue Host.isPrivateAddress mappedPrivate + ut\assertFalse Host.isPrivateAddress mappedPublic + ut\assertFalse Host.isPrivateAddress publicV6 + + -- getUrlHostPart: scheme, userinfo, port, and bracketed IPv6 stripping + getUrlHostPart_variants: (ut) -> + ut\assertEquals Host.getUrlHostPart("https://example.com/feed.json"), "example.com" + ut\assertEquals Host.getUrlHostPart("http://user:pass@10.0.0.1:8080/x"), "10.0.0.1" + ut\assertEquals Host.getUrlHostPart("https://[::1]:443/x"), "::1" + ut\assertEquals Host.getUrlHostPart("http://127.0.0.1"), "127.0.0.1" + ut\assertEquals Host.getUrlHostPart("https://Example.COM/"), "Example.COM" + -- userinfo containing '@' must strip to the LAST '@', as the fetcher does, so the real host is checked + ut\assertEquals Host.getUrlHostPart("http://a@b@127.0.0.1/x"), "127.0.0.1" + + getUrlHostPart_noHost: (ut) -> + ut\assertNil Host.getUrlHostPart "file:///etc/passwd" + ut\assertNil Host.getUrlHostPart 42 + + -- parseIPv4Literal: the inet_aton encodings that would otherwise bypass a naive host check + parseIPv4Literal_encodings: (ut) -> + ut\assertEquals Host.parseIPv4Literal("127.0.0.1"), {127, 0, 0, 1} + ut\assertEquals Host.parseIPv4Literal("2130706433"), {127, 0, 0, 1} -- decimal + ut\assertEquals Host.parseIPv4Literal("0x7f000001"), {127, 0, 0, 1} -- hex + ut\assertEquals Host.parseIPv4Literal("0177.0.0.1"), {127, 0, 0, 1} -- octal first octet + ut\assertEquals Host.parseIPv4Literal("127.1"), {127, 0, 0, 1} -- 2-part fill + ut\assertEquals Host.parseIPv4Literal("192.168.1"), {192, 168, 0, 1} -- 3-part fill + + parseIPv4Literal_notLiterals: (ut) -> + ut\assertNil Host.parseIPv4Literal "example.com" + ut\assertNil Host.parseIPv4Literal "256.1.1.1" -- octet out of range + ut\assertNil Host.parseIPv4Literal "1.2.3.4.5" -- too many parts + ut\assertNil Host.parseIPv4Literal ".1.2.3" -- leading dot + ut\assertNil Host.parseIPv4Literal "1.2..3" -- empty component + ut\assertNil Host.parseIPv4Literal "::1" -- not IPv4 + + -- isPrivate: literal hosts classified without the resolver + isPrivate_literalHost: (ut) -> + neverResolve = (host) -> error "resolver must not be called for a literal host" + ut\assertTrue (Host "127.0.0.1", neverResolve).isPrivate + ut\assertTrue (Host "2130706433", neverResolve).isPrivate -- decimal-encoded loopback + ut\assertFalse (Host "93.184.216.34", neverResolve).isPrivate + + -- isPrivate: named hosts resolved through the injected resolver; any private address counts + isPrivate_resolvedHost: (ut) -> + resolve = resolverOver { + "internal.example": {{10, 0, 0, 5}} + "evil.example": {{93, 184, 216, 34}} + "rebind.example": {{93, 184, 216, 34}, {127, 0, 0, 1}} -- one public, one loopback + "::1": {{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}} + } + ut\assertTrue (Host "internal.example", resolve).isPrivate + ut\assertFalse (Host "evil.example", resolve).isPrivate + ut\assertTrue (Host "rebind.example", resolve).isPrivate + ut\assertTrue (Host "::1", resolve).isPrivate + + -- a failed lookup (resolver returns nil) leaves a non-literal host not-private + isPrivate_unresolved: (ut) -> + ut\assertFalse (Host "unknown.example", resolverOver {}).isPrivate + ut\assertFalse (Host "unknown.example", (-> nil)).isPrivate + + -- resolution is cached: the resolver runs once across repeated queries + addresses_cachesResolution: (ut) -> + calls = 0 + resolve = (host) -> + calls += 1 + {{10, 0, 0, 1}} + host = Host "internal.example", resolve + host.isPrivate + host.isPrivate + ut\assertEquals calls, 1 + + -- fromUrl builds a Host from a URL's host, or nil when there is none + fromUrl_extractsHost: (ut) -> + resolve = resolverOver {"internal.example": {{10, 0, 0, 5}}} + ut\assertTrue (Host.fromUrl "https://internal.example/feed", resolve).isPrivate + ut\assertTrue (Host.fromUrl "http://127.0.0.1/x").isPrivate + ut\assertNil Host.fromUrl "file:///etc/passwd" + + _order: { + "isPrivateAddress_ipv4Private", "isPrivateAddress_ipv4Public", "isPrivateAddress_ipv6" + "getUrlHostPart_variants", "getUrlHostPart_noHost" + "parseIPv4Literal_encodings", "parseIPv4Literal_notLiterals" + "isPrivate_literalHost", "isPrivate_resolvedHost", "isPrivate_unresolved" + "addresses_cachesResolution", "fromUrl_extractsHost" + } + } diff --git a/modules/l0/DependencyControl/test/JsonSchema.moon b/modules/l0/DependencyControl/test/JsonSchema.moon new file mode 100644 index 0000000..7b2e256 --- /dev/null +++ b/modules/l0/DependencyControl/test/JsonSchema.moon @@ -0,0 +1,96 @@ +-- 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" + + -- a document's own root `$schema` names its version, overriding the caller's hint + validateAny_readsVersionFromSchemaId: (ut) -> + data = {["$schema"]: "https://host/schemas/config/v0.7.0.json"} + -- the hint says 0.6.3, but the data's $schema says 0.7.0, so 0.7.0 wins + isValid, version = JsonSchema\validateAny data, {"0.7.0": mockSchema(true), "0.6.3": mockSchema(false, "no")}, "0.6.3" + ut\assertTrue isValid + ut\assertEquals version, "0.7.0" + + -- the version named by `$schema` is authoritative even when it rejects: no fallthrough to a lenient schema + validateAny_schemaIdRejectionDoesNotFallThrough: (ut) -> + data = {["$schema"]: "https://host/v0.7.0.json"} + isValid, version = JsonSchema\validateAny data, {"0.7.0": mockSchema(false, "bad"), "0.6.3": mockSchema true}, nil + ut\assertFalse isValid + ut\assertEquals version, "0.7.0" -- did not fall through to the lenient 0.6.3 + + -- for data without a `$schema`, the hint may be a function that derives the version from the data itself + -- (e.g. a feed reading its legacy `dependencyControlFeedFormatVersion` field) + validateAny_versionHintFunction: (ut) -> + data = {feedFormat: "0.3.0"} + isValid, version = JsonSchema\validateAny data, {"0.3.0": mockSchema(true)}, ((d) -> d.feedFormat) + ut\assertTrue isValid + ut\assertEquals version, "0.3.0" + + _order: { + "getSchemasInDirectory_mapsVersionsToPaths", "getSchemasInDirectory_noneFound", + "getSchemasInDirectory_dirReadError", + "validateAny_exactVersionValid", "validateAny_reportsInvalidWithError", + "validateAny_fallsThroughToOtherVersions", "validateAny_aggregatesAllFailures", + "validateAny_readsVersionFromSchemaId", "validateAny_schemaIdRejectionDoesNotFallThrough", + "validateAny_versionHintFunction" + } + } diff --git a/modules/l0/DependencyControl/test/Lock.moon b/modules/l0/DependencyControl/test/Lock.moon new file mode 100644 index 0000000..60c6c5e --- /dev/null +++ b/modules/l0/DependencyControl/test/Lock.moon @@ -0,0 +1,430 @@ +-- 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 + + -- state + + state_initial: (ut) -> + lock = Lock namespace: "ns", resource: "res" + ut\assertEquals lock.state, Lock.LockState.Unknown + + state_held: (ut) -> + installFakeSemaphore ut, true + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res", recordHolder: false + lock\lock! + ut\assertEquals lock.state, 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 + sleepStub = 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 + ut\assertEquals timePassed, 0 -- no wait happened, so none may be reported + sleepStub\assertNotCalled! + 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 finalizer: unreleased lock is cleaned up and warns on collection + + gc_finalizer: (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 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", + "state_initial", "state_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_finalizer" + } + } diff --git a/modules/l0/DependencyControl/test/Logger.moon b/modules/l0/DependencyControl/test/Logger.moon new file mode 100644 index 0000000..fc01a83 --- /dev/null +++ b/modules/l0/DependencyControl/test/Logger.moon @@ -0,0 +1,151 @@ +-- Logger tests: message formatting, dump serialization, and log dispatch. +-- Called from Tests.moon as: (require "...test.Logger")! +-> + Logger = require "l0.DependencyControl.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! + + -- a log file that can't be opened disables file logging instead of crashing on every subsequent call + log_fileOpenFailureDisablesToFile: (ut) -> + logger = Logger toFile: true, toWindow: false, logDir: "?temp" + (ut\stub io, "open")\calls -> nil -- simulate the log file failing to open + ut\stub aegisub, "log" -- swallow the one-time warning + ok = pcall -> logger\log 4, "hello" + ut\assertTrue ok -- no crash + ut\assertFalse logger.toFile -- file logging disabled after the failure + + -- the usePrefix shorthand configures both sinks (regression: a multi-assign from the + -- single value left the window flag on its default) + usePrefix_setsBothSinks: (ut) -> + logger = Logger toFile: false, toWindow: false, usePrefix: false + ut\assertFalse logger.usePrefixFile + ut\assertFalse logger.usePrefixWindow + + -- a progress bar's fill chunks continue the logger's own open window line, so they must + -- not repeat the indent prefix inside the bar + progress_noIndentInsideBar: (ut) -> + captured = {} + (ut\stub aegisub, "log")\calls (level, msg) -> captured[#captured + 1] = msg + logger = Logger toFile: false, toWindow: true, indent: 2 + logger\progress 10, "Downloading..." + logger\progress 50 + logger\progress! + out = table.concat captured + ut\assertContains out, "—— Downloading" -- the opening chunk keeps its indent + bar = out\match "%[(.-)%]" + ut\assertEquals bar, "■"\rep 10 + + -- 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", "log_fileOpenFailureDisablesToFile", + "usePrefix_setsBothSinks", "progress_noIndentInsideBar", + "assert_truthy", "assert_falsy", + "assertNotNil_value", "assertNotNil_nil" + } + } diff --git a/modules/l0/DependencyControl/test/ModuleLoader.moon b/modules/l0/DependencyControl/test/ModuleLoader.moon new file mode 100644 index 0000000..ffdcce7 --- /dev/null +++ b/modules/l0/DependencyControl/test/ModuleLoader.moon @@ -0,0 +1,319 @@ +-- ModuleLoader tests: internal module loading helpers. +-- Called from Tests.moon as: (require "...test.ModuleLoader")! +-> + constants = require "l0.DependencyControl.Constants" + Common = require "l0.DependencyControl.Common" + ModuleLoader = require "l0.DependencyControl.ModuleLoader" + ModuleProvider = require "l0.DependencyControl.ModuleProvider" + SemanticVersion = require "l0.DependencyControl.SemanticVersion" + + DEPCTRL_DUMMY_MODULE_MARKER = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Dummy" + + { + _description: "Tests for ModuleLoader internal module loading helpers." + + -- formatVersionErrorTemplate: pure computation, uses SemanticVersion.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, "" + + -- loadModules: a missing module is fetched through the updater; on success it's marked updated. + -- @@ (the host record's class) must be callable (constructs the to-fetch record) and carry an + -- `updater` whose `require` performs the fetch. + loadModules_missingFetchedViaUpdater: (ut) -> + ns = "test.ModuleLoader.missingFetch" + mockRef = {fetched: true} + updater = {require: ((...) => mockRef)} + recClass = setmetatable {ScriptType: Common.ScriptType, __name: "DependencyControl", :updater}, + {__call: (cls, args) -> {}} + rec = {feed: nil, moduleName: "host.Module", name: "host", __class: recClass} + mdl = {moduleName: ns, name: ns, version: nil} + (ut\stub ModuleLoader, "loadModule")\calls (self, m, usePrivate) -> m._missing = true unless usePrivate + success, err = ModuleLoader.loadModules rec, {mdl} + ut\assertTrue success + ut\assertEquals err, "" + ut\assertTrue mdl._updated + ut\assertFalse mdl._missing + + -- loadModules: a missing *required* module the updater can't fetch fails, and the circular-dependency + -- dummy ref is cleared. + loadModules_missingRequiredFails: (ut) -> + ns = "test.ModuleLoader.missingFail" + updaterClass = {getUpdaterErrorMsg: (code, name) -> "fetch failed: #{name}"} + updater = {require: ((...) => return nil, -6, "no feed"), __class: updaterClass} + recClass = setmetatable {ScriptType: Common.ScriptType, __name: "DependencyControl", :updater}, + {__call: (cls, args) -> {}} + rec = {feed: nil, moduleName: "host.Module", name: "host", __class: recClass} + mdl = {moduleName: ns, name: ns, version: nil, optional: false} + (ut\stub ModuleLoader, "loadModule")\calls (self, m, usePrivate) -> m._missing = true unless usePrivate + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = {dummy: true} + success, err = ModuleLoader.loadModules rec, {mdl} + ut\assertFalse success + ut\assertContains err, ns + ut\assertNil LOADED_MODULES[ns] -- dummy ref nuked + + -- loadModules: a missing *optional* module the updater skips is left missing without an error + -- reason and doesn't fail the overall load; the circular-dependency dummy ref is still cleared. + loadModules_missingOptionalSkipped: (ut) -> + ns = "test.ModuleLoader.missingOptionalSkip" + UpdateTask = require "l0.DependencyControl.UpdateTask" + updater = {require: ((...) => return nil, UpdateTask.UpdateStatus.SkippedOptional)} + recClass = setmetatable {ScriptType: Common.ScriptType, __name: "DependencyControl", :updater}, + {__call: (cls, args) -> {}} + rec = {feed: nil, moduleName: "host.Module", name: "host", __class: recClass} + mdl = {moduleName: ns, name: ns, version: nil, optional: true} + (ut\stub ModuleLoader, "loadModule")\calls (self, m, usePrivate) -> m._missing = true unless usePrivate + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = {dummy: true} + success, err = ModuleLoader.loadModules rec, {mdl} + ut\assertTrue success + ut\assertEquals err, "" + ut\assertNil mdl._reason + ut\assertNil LOADED_MODULES[ns] -- dummy ref nuked + + -- loadModules: a required module that fails because one of ITS OWN requirements couldn't be satisfied + -- surfaces the nested reason (which sub-requirement failed, and why) in the error — using the real + -- getUpdaterErrorMsg so the RequirementsUnmet template's detail isn't dropped on the way to the UI. + loadModules_requirementsUnmetSurfacesNestedReason: (ut) -> + ns = "l0.ASSFoundation" + UpdateTask = require "l0.DependencyControl.UpdateTask" + innerReason = "— SubInspector.Inspector (v0.7.2)\n—— Reason: no build for your platform (Linux-x64)" + updater = {require: ((...) => return nil, UpdateTask.UpdateStatus.RequirementsUnmet, innerReason), __class: UpdateTask} + recClass = setmetatable {ScriptType: Common.ScriptType, __name: "DependencyControl", :updater}, + {__call: (cls, args) -> {}} + rec = {feed: nil, moduleName: "host.Module", name: "Vector Gradient", __class: recClass} + mdl = {moduleName: ns, name: ns, version: "0.5.0", optional: false} + (ut\stub ModuleLoader, "loadModule")\calls (self, m, usePrivate) -> m._missing = true unless usePrivate + success, err = ModuleLoader.loadModules rec, {mdl} + ut\assertFalse success + ut\assertContains err, "requirements could not be satisfied" + ut\assertContains err, "SubInspector.Inspector" + ut\assertContains err, "no build for your platform" + + -- loadModules: an outdated installed module is force-updated through the updater; the fresh ref + -- replaces the loaded one. (isDepCtrlVersionRecord is stubbed so the loaded version record is used + -- as-is rather than wrapped in an unmanaged record.) + loadModules_outdatedForcesUpdate: (ut) -> + ns = "test.ModuleLoader.outdated" + newRef = {updated: true} + loadedRef = {version: {version: 65793, checkVersion: ((target) => false)}} -- installed but too old + updater = {require: ((...) => newRef)} + recClass = setmetatable {ScriptType: Common.ScriptType, __name: "DependencyControl", :updater}, + {__call: (cls, args) -> {}} + rec = {feed: nil, moduleName: "host.Module", name: "host", __class: recClass} + mdl = {moduleName: ns, name: ns, version: SemanticVersion\toPacked "2.0.0"} + (ut\stub ModuleLoader, "loadModule")\calls (self, m, usePrivate) -> m._ref = loadedRef unless usePrivate + ut\stub(ModuleProvider, "isDepCtrlVersionRecord")\returns true + success, err = ModuleLoader.loadModules rec, {mdl} + ut\assertTrue success + ut\assertEquals err, "" + ut\assertEquals mdl._ref, newRef + + -- loadModules: an outdated *required* module the updater can't update fails with an "outdated" error + loadModules_outdatedRequiredFails: (ut) -> + ns = "test.ModuleLoader.outdatedFail" + loadedRef = {version: {version: 65793, checkVersion: ((target) => false)}} + updaterClass = {getUpdaterErrorMsg: (code, name) -> "too old: #{name}"} + updater = {require: ((...) => return nil, -6, "no newer version"), __class: updaterClass} + recClass = setmetatable {ScriptType: Common.ScriptType, __name: "DependencyControl", :updater}, + {__call: (cls, args) -> {}} + rec = {feed: nil, moduleName: "host.Module", name: "host", __class: recClass} + mdl = {moduleName: ns, name: ns, version: SemanticVersion\toPacked "2.0.0", optional: false} + (ut\stub ModuleLoader, "loadModule")\calls (self, m, usePrivate) -> m._ref = loadedRef unless usePrivate + ut\stub(ModuleProvider, "isDepCtrlVersionRecord")\returns true + success, err = ModuleLoader.loadModules rec, {mdl} + ut\assertFalse success + ut\assertContains err, ns + + -- 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", + "loadModules_missingFetchedViaUpdater", "loadModules_missingRequiredFails", + "loadModules_missingOptionalSkipped", "loadModules_requirementsUnmetSurfacesNestedReason", + "loadModules_outdatedForcesUpdate", "loadModules_outdatedRequiredFails", + "checkOptionalModules_noneOptional", "checkOptionalModules_missingOptional" + } + } diff --git a/modules/l0/DependencyControl/test/ModuleProvider.moon b/modules/l0/DependencyControl/test/ModuleProvider.moon new file mode 100644 index 0000000..b0682f2 --- /dev/null +++ b/modules/l0/DependencyControl/test/ModuleProvider.moon @@ -0,0 +1,87 @@ +-- 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" + SemanticVersion = require "l0.DependencyControl.SemanticVersion" + + 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.SemanticVersion" + resolved = require name + package.loaded[name] = nil -- don't leak the alias into the module cache + ut\assertIs resolved, SemanticVersion + + -- 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! + + -- a failing initializer reports the module's name and the real error + runInitializer_reportsErrorWithModuleName: (ut) -> + ref = {version: "raw", moduleName: "l0.Exploder"} + (ut\stub ref, DEPCTRL_MODULE_INIT_HOOK_NAME)\calls -> error "boom" + result, err = ModuleProvider.runInitializer ref, makeDepCtrlClassMock! + ut\assertNil result + ut\assertContains err, "l0.Exploder" + ut\assertContains err, "boom" + + _order: { + "register_andGetProvider", "register_firstWins", + "registerRecord_normalizesAliases", "searcher_resolvesAliasToProvider", + "runInitializer_noInitHook", "runInitializer_runsWhenUninitialized", + "runInitializer_skipsWhenInitialized", "runInitializer_reportsErrorWithModuleName" + } + } diff --git a/modules/l0/DependencyControl/test/NamedSemaphore.moon b/modules/l0/DependencyControl/test/NamedSemaphore.moon new file mode 100644 index 0000000..7c68a1a --- /dev/null +++ b/modules/l0/DependencyControl/test/NamedSemaphore.moon @@ -0,0 +1,60 @@ +-- NamedSemaphore tests: the named binary-semaphore primitive, exercised against real OS semaphores +-- (CreateSemaphoreA on Windows, sem_open on POSIX). Skipped where the semaphore FFI is unavailable. +-- Called from test.moon as: (controls\requireTest "NamedSemaphore")! +() -> + NamedSemaphore = require "l0.DependencyControl.NamedSemaphore" + + -- a fresh, random [A-Za-z0-9_] token per call so concurrent runs don't collide on a persisted POSIX name + token = -> "DepCtrlTest_#{'%08X'\format math.random 0, 16^8-1}" + + { + _description: "NamedSemaphore: the cross-process binary semaphore, against real OS semaphores." + _condition: -> NamedSemaphore.isAvailable, "no semaphore FFI on this platform/build" + + pidIsExposed: (ut) -> + ut\assertEquals type(NamedSemaphore.pid), "number" + + -- a binary semaphore acquires once (1 -> 0), refuses a second (non-reentrant) tryLock, then releases + -- and can be acquired again. unlinkOnClose cleans up the OS name on POSIX (no effect on Windows). + acquiresExclusivelyAndReleases: (ut) -> + sem = NamedSemaphore token!, true + ut\assertTrue sem.isOpen + ut\assertTrue sem\tryLock! + ut\assertFalse sem\tryLock! + ut\assertTrue sem\unlock! + ut\assertTrue sem\tryLock! + sem\unlock! + + -- a blocking lock() on an available semaphore acquires immediately (without blocking) + blockingLockAcquires: (ut) -> + sem = NamedSemaphore token!, true + ut\assertTrue sem\lock! + sem\unlock! + + -- two handles to the same name share the one kernel semaphore and contend + sameNameContends: (ut) -> + name = token! + a, b = NamedSemaphore(name, true), NamedSemaphore(name, true) + ut\assertTrue a\tryLock! + ut\assertFalse b\tryLock! + a\unlock! + ut\assertTrue b\tryLock! + b\unlock! + + -- collecting one handle must not unlink a name another holder still owns: on POSIX a per-instance + -- sem_unlink would let the next sem_open create a separate semaphore, so a fresh handle could acquire + -- the "same" lock while the first holder still holds it (split-brain). Windows self-heals via refcount. + collectingOneHandleKeepsExclusion: (ut) -> + name = token! + a = NamedSemaphore name, true + ut\assertTrue a\tryLock! -- a holds it (value 0) + b = NamedSemaphore name, true -- a second handle to the same name... + b = nil + collectgarbage "collect" for _ = 1, 3 -- ...is collected (loop forces the finalizer to run); the name must survive + c = NamedSemaphore name, true -- a fresh open must see the same, still-held semaphore + ut\assertFalse c\tryLock! -- so it cannot acquire while a holds it + a\unlock! + + _order: {"pidIsExposed", "acquiresExclusivelyAndReleases", "blockingLockAcquires", "sameNameContends" + "collectingOneHandleKeepsExclusion"} + } diff --git a/modules/l0/DependencyControl/test/Record.moon b/modules/l0/DependencyControl/test/Record.moon new file mode 100644 index 0000000..ae8e8ae --- /dev/null +++ b/modules/l0/DependencyControl/test/Record.moon @@ -0,0 +1,435 @@ +-- Record tests: extracted from the main test suite. +-- Called from test.moon as: (controls\requireTest "Record") basePath +(basePath) -> + ffi = require "ffi" + constants = require "l0.DependencyControl.Constants" + Common = require "l0.DependencyControl.Common" + FileOps = require "l0.DependencyControl.FileOps" + Record = require "l0.DependencyControl.Record" + Stub = require "l0.DependencyControl.Stub" + {:stubSelf} = require "l0.DependencyControl.test.helpers.stub-helpers" + + DEPCTRL_RECORDS_GLOBAL_KEY = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Records" + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + + uniqueName = (prefix) -> "#{prefix}_#{'%08X'\format math.random 0, 16^8-1}" + + -- 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, 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 + + -- 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 <namespace>/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 + + 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.semanticVersion\toPacked!, 131844 -- stored on the canonical instance + + 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 + + -- the `version` accessor: a packed-int view over the canonical @semanticVersion instance + version_accessorGetsAndSets: (ut) -> + SemanticVersion = require "l0.DependencyControl.SemanticVersion" + -- a real Record-metatabled instance so `version` dispatches through the installed accessor + rec = setmetatable {semanticVersion: SemanticVersion "1.2.3"}, Record.__base + ut\assertEquals rec.version, SemanticVersion\toPacked "1.2.3" -- getter yields the packed int + rec.version = "2.0.0" -- setter accepts a string + ut\assertEquals tostring(rec.semanticVersion), "2.0.0" -- rebuilt the canonical instance + ut\assertEquals rec.version, SemanticVersion\toPacked "2.0.0" + + 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" + + -- uninstall removes only the record's own files: a module's directory must equal the last + -- namespace part exactly, so a sibling package sharing the name prefix survives the + -- recursive delete + uninstall_moduleSparesPrefixSiblings: (ut) -> + root = FileOps.joinPath basePath, "uninst-mod" + ut\assertString root + FileOps.mkdir FileOps.joinPath(root, "l0", "Functional"), false, true + FileOps.mkdir FileOps.joinPath(root, "l0", "FunctionalExtras"), false, true + FileOps.writeFile FileOps.joinPath(root, "l0", "Functional.moon"), "-- mod", true + FileOps.writeFile FileOps.joinPath(root, "l0", "Functional", "sub.moon"), "-- sub", true + FileOps.writeFile FileOps.joinPath(root, "l0", "FunctionalExtras.moon"), "-- sibling", true + FileOps.writeFile FileOps.joinPath(root, "l0", "FunctionalExtras", "sub.moon"), "-- sibling sub", true + rec = { + virtual: false, recordType: Common.RecordType.Managed, + scriptType: Common.ScriptType.Module, + namespace: "l0.Functional", moduleName: "l0.Functional", + automationDir: root, + config: {delete: ->}, getSubmodules: -> nil, + __class: {RecordType: Common.RecordType, terms: Common.terms} + } + success, results = Record.uninstall rec + ut\assertTrue success + ut\assertTable results + ut\assertFalse FileOps.exists FileOps.joinPath(root, "l0", "Functional.moon"), "file" + ut\assertFalse FileOps.exists FileOps.joinPath(root, "l0", "Functional"), "directory" + ut\assertTrue FileOps.exists FileOps.joinPath(root, "l0", "FunctionalExtras.moon"), "file" + ut\assertTrue FileOps.exists FileOps.joinPath(root, "l0", "FunctionalExtras", "sub.moon"), "file" + + -- automation file matching anchors the namespace on both sides and escapes pattern magic + -- (a hyphen would otherwise act as a lazy quantifier) + uninstall_automationEscapesAndTerminates: (ut) -> + root = FileOps.joinPath basePath, "uninst-auto" + ut\assertString root + FileOps.mkdir root, false, true + FileOps.writeFile FileOps.joinPath(root, "a-mo.Script.moon"), "-- macro", true + FileOps.writeFile FileOps.joinPath(root, "a-mo.ScriptExtra.moon"), "-- other macro", true + FileOps.writeFile FileOps.joinPath(root, "amo.Script.moon"), "-- hyphen bait", true + rec = { + virtual: false, recordType: Common.RecordType.Managed, + scriptType: Common.ScriptType.Automation, + namespace: "a-mo.Script", + automationDir: root, + config: {delete: ->}, getSubmodules: -> nil, + __class: {RecordType: Common.RecordType, terms: Common.terms} + } + success, results = Record.uninstall rec + ut\assertTrue success + ut\assertTable results + ut\assertFalse FileOps.exists FileOps.joinPath(root, "a-mo.Script.moon"), "file" + ut\assertTrue FileOps.exists FileOps.joinPath(root, "a-mo.ScriptExtra.moon"), "file" + ut\assertTrue FileOps.exists FileOps.joinPath(root, "amo.Script.moon"), "file" + + 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, + registeredMacros: {}, + __class: {updater: updaterMock} + } + process = (->) + Record.registerMacro rec, "MyMacro", "My macro", process + ut\assertEquals #registered, 1 + ut\assertContains registered[1][1], "MyMacro" + registerTestsStub\assertCalledOnceWith rec + -- the macro is recorded under its name, exposing the unhooked process to the test suite + ut\assertIs rec.registeredMacros.MyMacro.process, process + + -- 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 + + -- Regression: the constructor must populate @provides (normalizing bare strings to ModuleAlias + -- tables) and register every alias with the module-provides searcher. A field/local mix-up that + -- left @provides nil silently disabled both alias resolution and update-feed `provides` mirroring. + construct_populatesAndRegistersProvides: (ut) -> + ModuleProvider = require "l0.DependencyControl.ModuleProvider" + ns, alias = uniqueName("prov.mod"), uniqueName "alias" + rec = Record {moduleName: ns, version: "1.0.0", feed: "https://example.com/feed.json", + provides: {{name: alias, version: "^1"}, "bare.#{ns}"}} + ut\assertNotNil rec.provides + ut\assertEquals #rec.provides, 2 + ut\assertEquals rec.provides[1].name, alias + ut\assertEquals rec.provides[1].version, "^1" + ut\assertEquals rec.provides[2].name, "bare.#{ns}" -- bare string normalized to a table + ut\assertEquals ModuleProvider\getProvider(alias), ns + ut\assertEquals ModuleProvider\getProvider("bare.#{ns}"), ns + + -- getFileCache: a shared cache under the configured cache base, this script's namespace, and the given name + getFileCache_namespacedUnderConfigBase: (ut) -> + fakeSelf = {namespace: "l0.test.script", __class: {config: {c: {paths: {cache: "?user/cache"}}}}} + cache = Record.getFileCache fakeSelf, "thumbnails" + ut\assertNotNil cache + ut\assertMatches cache.cacheDir, "l0%.test%.script/thumbnails$" + + -- getVersionNumber/getVersionString: deprecated <=0.6.x compat methods — callable on an instance and + -- defaulting to the record's own version (regression: they'd been class fields, unreachable via rec\method) + getVersion_compatMethods: (ut) -> + SemanticVersion = require "l0.DependencyControl.SemanticVersion" + fakeSelf = stubSelf Record, {version: SemanticVersion\toPacked "1.2.3"} + ut\assertEquals fakeSelf\getVersionString!, "1.2.3" -- defaults to @version + ut\assertEquals fakeSelf\getVersionNumber("2.0.0"), SemanticVersion\toPacked "2.0.0" + ut\assertEquals fakeSelf\getVersionString(SemanticVersion\toPacked "3.1.0"), "3.1.0" + + -- loadConfig imports recordType from the stored config like any other persisted field + loadConfig_importsRecordType: (ut) -> + record = stubSelf Record, { + __class: Record, virtual: false, namespace: "l0.x", scriptType: Common.ScriptType.Module + config: {load: (=> true), c: {recordType: Common.RecordType.Unmanaged}} + } + Record.__base.loadConfig record, true + ut\assertEquals record.recordType, Common.RecordType.Unmanaged + + _order: { + "getFileCache_namespacedUnderConfigBase", "getVersion_compatMethods", + "loadConfig_importsRecordType", + "module_dataOnly", "module_initLayout", "module_alsoUnderUser", "module_userOnly", + "notInstalled", "portable", "macro_dataOnly", + "checkVersion_equal", "checkVersion_greater", "checkVersion_older", "checkVersion_recordArg", + "setVersion_validString", "setVersion_validNumber", "setVersion_invalid", "version_accessorGetsAndSets", + "validateNamespace_valid", "validateNamespace_invalid_noDot", + "validateNamespace_invalid_trailingDot", "validateNamespace_virtual", + "uninstall_virtual", "uninstall_unmanaged", + "uninstall_moduleSparesPrefixSiblings", "uninstall_automationEscapesAndTerminates", + "getSubmodules_virtual", "getSubmodules_unmanaged", "getSubmodules_nonModule", + "getConfigFileName_basic", "registerMacro_basic", + "registry_getReturnsRegistered", "registry_getMissing", + "registry_getSkipsVirtual", "registry_returnsAfterUnvirtualized", + "registry_getRegisteredReturnsCopy", "registry_getRegisteredIncludesVirtual", + "construct_populatesAndRegistersProvides" + } + } 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/ScriptUpdateRecord.moon b/modules/l0/DependencyControl/test/ScriptUpdateRecord.moon new file mode 100644 index 0000000..98a0ec2 --- /dev/null +++ b/modules/l0/DependencyControl/test/ScriptUpdateRecord.moon @@ -0,0 +1,98 @@ +-- ScriptUpdateRecord tests: channel management and update record accessors. +-- Called from Tests.moon as: (require "...test.ScriptUpdateRecord")! +-> + Common = require "l0.DependencyControl.Common" + ScriptUpdateRecord = require "l0.DependencyControl.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"), "" + + -- a malformed changelog version key (unvalidated feed data) is skipped, not crashed on + getChangelog_skipsMalformedKey: (ut) -> + data = { + channels: {release: {default: true, version: "1.0.0", files: {}}}, name: "TestScript" + changelog: {["1.0.0"]: {"Initial release"}, ["not-a-version"]: {"Bogus"}} + } + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module + result = sur\getChangelog nil + ut\assertContains result, "Initial release" -- valid entry still rendered + ut\assertString result -- and no crash on the malformed key + + _order: { + "getChannels_basic", "getChannels_noDefault", + "setChannel_valid", "setChannel_invalid", + "checkPlatform_noConstraint", "checkPlatform_currentPlatform", "checkPlatform_notMatching", + "getChangelog_noTable", "getChangelog_inRange", "getChangelog_allOutOfRange", + "getChangelog_skipsMalformedKey" + } + } diff --git a/modules/l0/DependencyControl/test/SemanticVersion.moon b/modules/l0/DependencyControl/test/SemanticVersion.moon new file mode 100644 index 0000000..5d26ef2 --- /dev/null +++ b/modules/l0/DependencyControl/test/SemanticVersion.moon @@ -0,0 +1,314 @@ +-- SemanticVersion tests: the static utilities (toPacked, toString, check, ranges) and the version +-- value instance (construction, comparison, bumping, and interop with the static layer). +-- Called from Tests.moon as: (require "...test.SemanticVersion")! +-> + SemanticVersion = require "l0.DependencyControl.SemanticVersion" + + { + _description: "Tests for SemanticVersion: static version/range utilities plus the version value instance." + + -- toPacked + + toPacked_string: (ut) -> + result, err = SemanticVersion\toPacked "1.2.3" + ut\assertEquals result, 66051 + ut\assertNil err + + toPacked_zero: (ut) -> + result, err = SemanticVersion\toPacked "0.0.0" + ut\assertEquals result, 0 + ut\assertNil err + + toPacked_number: (ut) -> + result = SemanticVersion\toPacked 66051 + ut\assertEquals result, 66051 + + toPacked_nil: (ut) -> + result = SemanticVersion\toPacked nil + ut\assertEquals result, 0 + + toPacked_badString: (ut) -> + result, err = SemanticVersion\toPacked "1.2" + ut\assertFalse result + ut\assertString err + + toPacked_overflow: (ut) -> + result, err = SemanticVersion\toPacked "1.256.0" + ut\assertFalse result + ut\assertString err + + toPacked_badType: (ut) -> + result, err = SemanticVersion\toPacked {} + ut\assertFalse result + ut\assertString err + + -- toString + + toString_fromNumber: (ut) -> + result, err = SemanticVersion\toString 66051 + ut\assertEquals result, "1.2.3" + ut\assertNil err + + toString_roundtrip: (ut) -> + result, err = SemanticVersion\toString "1.2.3" + ut\assertEquals result, "1.2.3" + ut\assertNil err + + toString_majorPrecision: (ut) -> + result = SemanticVersion\toString 66051, "major" + ut\assertEquals result, "1.0.0" + + toString_nilRendersZero: (ut) -> + result, err = SemanticVersion\toString nil + ut\assertEquals result, "0.0.0" + ut\assertNil err + + toString_fromInstance: (ut) -> + result = SemanticVersion\toString SemanticVersion "1.2.3" + ut\assertEquals result, "1.2.3" + + toString_badString: (ut) -> + result, err = SemanticVersion\toString "not-a-version" + ut\assertNil result + ut\assertString err + + -- check + + check_equal: (ut) -> + result, b = SemanticVersion\check "1.2.3", "1.2.3" + ut\assertTrue result + + check_greater: (ut) -> + result = SemanticVersion\check "2.0.0", "1.0.0" + ut\assertTrue result + + check_less: (ut) -> + result = SemanticVersion\check "1.0.0", "2.0.0" + ut\assertFalse result + + check_majorPrecision: (ut) -> + result = SemanticVersion\check "2.0.0", "1.9.9", "major" + ut\assertTrue result + + check_badArg: (ut) -> + result, err = SemanticVersion\check "bad", "1.0.0" + ut\assertNil result + ut\assertString err + + -- check with precision "range": b is an npm-style range string + + check_rangeMode: (ut) -> + ut\assertTrue SemanticVersion\check("1.2.9", "~1.2.3", "range") + ut\assertFalse SemanticVersion\check("1.3.0", "~1.2.3", "range") + + check_rangeBadRange: (ut) -> + result, err = SemanticVersion\check "1.2.3", "garbage", "range" + ut\assertNil result + ut\assertString err + + -- satisfiesRange: npm semver range syntax + + satisfiesRange_tilde: (ut) -> + ut\assertTrue SemanticVersion\satisfiesRange("1.2.3", "~1.2.3") + ut\assertTrue SemanticVersion\satisfiesRange("1.2.9", "~1.2.3") + ut\assertFalse SemanticVersion\satisfiesRange("1.3.0", "~1.2.3") + ut\assertTrue SemanticVersion\satisfiesRange("1.2.0", "~1.2") -- ~1.2 => >=1.2.0 <1.3.0 + ut\assertFalse SemanticVersion\satisfiesRange("2.0.0", "~1") -- ~1 => >=1.0.0 <2.0.0 + + satisfiesRange_caret: (ut) -> + ut\assertTrue SemanticVersion\satisfiesRange("1.9.9", "^1.2.3") + ut\assertFalse SemanticVersion\satisfiesRange("2.0.0", "^1.2.3") + ut\assertTrue SemanticVersion\satisfiesRange("0.2.9", "^0.2.3") -- 0.x: minor is left-most non-zero + ut\assertFalse SemanticVersion\satisfiesRange("0.3.0", "^0.2.3") + ut\assertTrue SemanticVersion\satisfiesRange("0.0.3", "^0.0.3") -- 0.0.x: patch is left-most non-zero + ut\assertFalse SemanticVersion\satisfiesRange("0.0.4", "^0.0.3") + + satisfiesRange_xRangeAndAny: (ut) -> + ut\assertTrue SemanticVersion\satisfiesRange("1.5.0", "1.x") + ut\assertFalse SemanticVersion\satisfiesRange("2.0.0", "1.x") + ut\assertTrue SemanticVersion\satisfiesRange("1.2.7", "1.2.x") + ut\assertTrue SemanticVersion\satisfiesRange("5.0.0", "*") + ut\assertTrue SemanticVersion\satisfiesRange("1.2.3", "") -- empty range => any + + satisfiesRange_exact: (ut) -> + ut\assertTrue SemanticVersion\satisfiesRange("1.2.3", "1.2.3") + ut\assertFalse SemanticVersion\satisfiesRange("1.2.4", "1.2.3") + + satisfiesRange_comparators: (ut) -> + ut\assertTrue SemanticVersion\satisfiesRange("1.5.0", ">=1.2.3 <2.0.0") + ut\assertFalse SemanticVersion\satisfiesRange("2.0.0", ">=1.2.3 <2.0.0") + ut\assertTrue SemanticVersion\satisfiesRange("2.0.0", ">1") -- >1 => >=2.0.0 + ut\assertFalse SemanticVersion\satisfiesRange("1.5.0", ">1") + ut\assertTrue SemanticVersion\satisfiesRange("1.3.0", ">1.2") -- >1.2 => >=1.3.0 + ut\assertTrue SemanticVersion\satisfiesRange("1.0.0", "<=1") -- <=1 => <2.0.0 + + satisfiesRange_orUnion: (ut) -> + ut\assertTrue SemanticVersion\satisfiesRange("0.9.0", "<1.0.0 || >=2.0.0") + ut\assertTrue SemanticVersion\satisfiesRange("2.1.0", "<1.0.0 || >=2.0.0") + ut\assertFalse SemanticVersion\satisfiesRange("1.5.0", "<1.0.0 || >=2.0.0") + + satisfiesRange_hyphen: (ut) -> + ut\assertTrue SemanticVersion\satisfiesRange("1.2.3", "1.2.3 - 2.3.4") + ut\assertTrue SemanticVersion\satisfiesRange("2.3.4", "1.2.3 - 2.3.4") -- inclusive upper + ut\assertFalse SemanticVersion\satisfiesRange("2.4.0", "1.2.3 - 2.3.4") + ut\assertTrue SemanticVersion\satisfiesRange("2.3.9", "1.2.3 - 2.3") -- partial upper => <2.4.0 + ut\assertFalse SemanticVersion\satisfiesRange("2.4.0", "1.2.3 - 2.3") + + satisfiesRange_errors: (ut) -> + r1, e1 = SemanticVersion\satisfiesRange "1.2.3", "garbage" + ut\assertNil r1 + ut\assertString e1 + r2, e2 = SemanticVersion\satisfiesRange "bad", "1.2.3" + ut\assertNil r2 + ut\assertString e2 + r3, e3 = SemanticVersion\satisfiesRange "1.2.3", nil + ut\assertNil r3 + ut\assertString e3 + + -- parseRange: range string -> set of half-open [min, max) intervals + + parseRange_intervals: (ut) -> + intervals = SemanticVersion\parseRange "~1.2.3" + ut\assertEquals #intervals, 1 + ut\assertEquals intervals[1].min, SemanticVersion\toPacked "1.2.3" + ut\assertEquals intervals[1].max, SemanticVersion\toPacked "1.3.0" -- exclusive upper + + parseRange_unsatisfiableIsEmpty: (ut) -> + intervals = SemanticVersion\parseRange ">2.0.0 <1.0.0" + ut\assertEquals #intervals, 0 + + parseRange_badType: (ut) -> + result, err = SemanticVersion\parseRange nil + ut\assertNil result + ut\assertString err + + -- rangesIntersect: do two ranges share any version? + + rangesIntersect_overlap: (ut) -> + ut\assertTrue SemanticVersion\rangesIntersect("~1.2.3", "~1.2") + ut\assertTrue SemanticVersion\rangesIntersect(">=1.0.0 <2.0.0", "^1.5.0") + ut\assertTrue SemanticVersion\rangesIntersect("1.x", "1.5.x") + + rangesIntersect_disjoint: (ut) -> + ut\assertFalse SemanticVersion\rangesIntersect("~1.2", "~1.3") -- [1.2,1.3) vs [1.3,1.4) adjacent + ut\assertFalse SemanticVersion\rangesIntersect("<1.0.0", ">=1.0.0") + ut\assertFalse SemanticVersion\rangesIntersect("1.x", "2.x") + + rangesIntersect_unionGroups: (ut) -> + ut\assertTrue SemanticVersion\rangesIntersect("^1.0.0 || ^2.0.0", "~2.0") + ut\assertFalse SemanticVersion\rangesIntersect("^1.0.0 || ^3.0.0", "~2.0") + + rangesIntersect_emptyAndExactBounds: (ut) -> + ut\assertFalse SemanticVersion\rangesIntersect(">2.0.0 <1.0.0", "*") -- contradictory range matches nothing + ut\assertTrue SemanticVersion\rangesIntersect("1.2.3 - 2.0.0", ">=2.0.0") -- inclusive upper meets >= + ut\assertFalse SemanticVersion\rangesIntersect("1.2.3 - 1.9.9", ">=2.0.0") + ut\assertFalse SemanticVersion\rangesIntersect("=1.2.3", "=1.2.4") + + rangesIntersect_error: (ut) -> + r, e = SemanticVersion\rangesIntersect "garbage", "1.2.3" + ut\assertNil r + ut\assertString e + + -- getRangeMaxVersion: the highest version a range can supply (for ranking competing ranges) + + getRangeMaxVersion_bounds: (ut) -> + ut\assertEquals SemanticVersion\getRangeMaxVersion("^1"), SemanticVersion\toPacked "1.255.255" + ut\assertEquals SemanticVersion\getRangeMaxVersion("~1.2"), SemanticVersion\toPacked "1.2.255" + ut\assertEquals SemanticVersion\getRangeMaxVersion("*"), SemanticVersion\toPacked "255.255.255" + + getRangeMaxVersion_unionTakesHighest: (ut) -> + ut\assertEquals SemanticVersion\getRangeMaxVersion("^1.0.0 || ^2.0.0"), SemanticVersion\toPacked "2.255.255" + + getRangeMaxVersion_emptyAndError: (ut) -> + ut\assertNil SemanticVersion\getRangeMaxVersion ">2.0.0 <1.0.0" -- empty range supplies nothing + r, e = SemanticVersion\getRangeMaxVersion "garbage" + ut\assertNil r + ut\assertString e + + -- instance API: construction, comparison, bumping, and interop with the static layer + + new_fromString: (ut) -> + v = SemanticVersion "1.2.3" + ut\assertEquals {v.major, v.minor, v.patch}, {1, 2, 3} + ut\assertEquals tostring(v), "1.2.3" + + new_fromComponents: (ut) -> + full = SemanticVersion 1, 2, 3 + ut\assertEquals {full.major, full.minor, full.patch}, {1, 2, 3} + ut\assertEquals tostring(SemanticVersion(1, 2)), "1.2.0" -- patch defaults to 0 + ut\assertEquals tostring(SemanticVersion(1)), "1.0.0" -- minor and patch default to 0 + + new_raisesOnInvalid: (ut) -> + ut\assertFalse (pcall -> SemanticVersion "nope") -- unparseable string + ut\assertFalse (pcall -> SemanticVersion 1, 2, 999) -- component out of range + ut\assertFalse (pcall -> SemanticVersion 1, -1, 0) -- negative component + + fromPacked_roundTripsAndValidates: (ut) -> + v = SemanticVersion "3.4.5" + ut\assertEquals tostring(SemanticVersion.fromPacked v\toPacked!), "3.4.5" + ut\assertFalse (pcall SemanticVersion.fromPacked, -1) + ut\assertFalse (pcall SemanticVersion.fromPacked, 0x1000000) + + parse_returnsInstanceOrError: (ut) -> + ut\assertEquals tostring(SemanticVersion.parse "1.2.3"), "1.2.3" + r, err = SemanticVersion.parse "garbage" + ut\assertNil r + ut\assertString err + r2, err2 = SemanticVersion.parse 5 -- non-string input + ut\assertNil r2 + ut\assertString err2 + + toPacked_andStaticUnwrap: (ut) -> + v = SemanticVersion "1.2.3" + ut\assertEquals v\toPacked!, SemanticVersion\toPacked "1.2.3" + ut\assertEquals SemanticVersion\toPacked(v), v\toPacked! -- static toPacked unwraps an instance + ut\assertTrue SemanticVersion.isHigher SemanticVersion("2.0.0"), v -- statics accept instances too + + compare_operators: (ut) -> + a = SemanticVersion "1.2.3" + ut\assertTrue a < SemanticVersion "1.2.4" + ut\assertTrue a <= SemanticVersion "1.2.3" + ut\assertTrue a == SemanticVersion "1.2.3" + ut\assertTrue a != SemanticVersion "2.0.0" + ut\assertFalse a > SemanticVersion "1.2.4" + + compare_mixedOperands: (ut) -> + a = SemanticVersion "1.2.3" + ut\assertTrue a < "1.2.4" -- against a string + ut\assertTrue a < SemanticVersion("1.2.4")\toPacked! -- against a packed number + ut\assertTrue (SemanticVersion("1.2.2")\toPacked!) < a -- number on the left operand + + bump_immutableAndResets: (ut) -> + a = SemanticVersion "1.2.3" + ut\assertEquals tostring(a\bumpMajor!), "2.0.0" -- minor/patch reset + ut\assertEquals tostring(a\bumpMinor!), "1.3.0" -- patch reset + ut\assertEquals tostring(a\bumpPatch!), "1.2.4" + ut\assertEquals tostring(a), "1.2.3" -- original is untouched + + satisfies_delegatesToRange: (ut) -> + v = SemanticVersion "1.2.3" + ut\assertTrue v\satisfies "^1.2.0" + ut\assertFalse v\satisfies "^2.0.0" + r, err = v\satisfies "garbage" + ut\assertNil r + ut\assertString err + + _order: { + "toPacked_string", "toPacked_zero", "toPacked_number", "toPacked_nil", + "toPacked_badString", "toPacked_overflow", "toPacked_badType", + "toString_fromNumber", "toString_roundtrip", "toString_majorPrecision", + "toString_nilRendersZero", "toString_fromInstance", "toString_badString", + "check_equal", "check_greater", "check_less", "check_majorPrecision", "check_badArg", + "check_rangeMode", "check_rangeBadRange", + "satisfiesRange_tilde", "satisfiesRange_caret", "satisfiesRange_xRangeAndAny", + "satisfiesRange_exact", "satisfiesRange_comparators", "satisfiesRange_orUnion", + "satisfiesRange_hyphen", "satisfiesRange_errors", + "parseRange_intervals", "parseRange_unsatisfiableIsEmpty", "parseRange_badType", + "rangesIntersect_overlap", "rangesIntersect_disjoint", "rangesIntersect_unionGroups", + "rangesIntersect_emptyAndExactBounds", "rangesIntersect_error", + "getRangeMaxVersion_bounds", "getRangeMaxVersion_unionTakesHighest", "getRangeMaxVersion_emptyAndError", + "new_fromString", "new_fromComponents", "new_raisesOnInvalid", + "fromPacked_roundTripsAndValidates", "parse_returnsInstanceOrError", "toPacked_andStaticUnwrap", + "compare_operators", "compare_mixedOperands", "bump_immutableAndResets", "satisfies_delegatesToRange" + } + } 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/UnitTestSuite.moon b/modules/l0/DependencyControl/test/UnitTestSuite.moon new file mode 100644 index 0000000..37e87e5 --- /dev/null +++ b/modules/l0/DependencyControl/test/UnitTestSuite.moon @@ -0,0 +1,233 @@ +-- UnitTestSuite tests: the unit-test framework's own assertions and run loop. +-- Called from test.moon as: (controls\requireTest "UnitTestSuite")! +-> + UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + UnitTest = UnitTestSuite.UnitTest + + -- A probe `self` for driving a single UnitTest assert method directly: its logger records the + -- failure message/args and raises (mirroring the real logger's level-1 assert) so a failing assert + -- is observable via pcall. `__class` lets `@@msgs`/`@format` resolve through UnitTest.__base. + makeProbe = -> + cap = {} + setmetatable { + __class: UnitTest + assertFailed: false + captured: cap + logger: { + -- checkArgTypes routes through logger.assert: valid args pass through, while a failed + -- check records the message and raises like the real logger's level-1 assert + assert: (cond, msg, ...) => + unless cond + cap.msg, cap.args = msg, table.pack ... + error "PROBE_ARGCHECK_FAILED" + cond + logEx: (level, msg, _insertLineFeed, _prefix, _indent, ...) => + cap.msg, cap.args = msg, table.pack ... + error "PROBE_ASSERT_FAILED" + dumpToString: (value) => tostring value -- assert methods dump their values eagerly + } + }, __index: UnitTest.__base + + -- Replaces every logger a constructed suite holds (suite + each class/test/setup/teardown) with a + -- no-op so a nested run() doesn't write to the live stream or a log file. Loggers are captured at + -- construction, so this must run after the suite is built. + silence = (suite) -> + -- logEx must keep raising on level 1: that raise is what makes a failing assertion fail + -- its test (see Logger.logEx). dumpToString is needed even by passing asserts, which + -- format their failure arguments eagerly. + noop = {indent: 0, log: (->), warn: (->), trace: (->), assert: ((cond) => cond), + logEx: ((level) => error "SILENCED_ASSERT_FAILED" if level == 1), + dumpToString: ((value) => tostring value)} + suite.logger = noop + for cls in *suite.classes + cls.logger, cls.setup.logger, cls.teardown.logger = noop, noop, noop + test.logger = noop for test in *cls.tests + suite + + { + _description: "Tests for the UnitTestSuite framework's own assertions and run loop." + + -- assertContains: regression for the inverted case-insensitivity and malformed failure message. + + assertContains_caseInsensitiveMatches: (ut) -> + probe = makeProbe! + ok = pcall UnitTest.assertContains, probe, "Hello World", "WORLD", false + ut\assertTrue ok + ut\assertFalse probe.assertFailed + + assertContains_caseSensitiveRejectsWrongCase: (ut) -> + probe = makeProbe! + ok = pcall UnitTest.assertContains, probe, "Hello World", "world" -- default: case-sensitive + ut\assertFalse ok + ut\assertTrue probe.assertFailed + + assertContains_failureUsesContainsMessage: (ut) -> + probe = makeProbe! + pcall UnitTest.assertContains, probe, "abc", "xyz" + ut\assertEquals probe.captured.msg, UnitTest.msgs.assert.contains + + -- assertNegative: regression for the wrong "positive" word and the missing value arg (the message + -- template's %d crashed on format because `actual` wasn't passed). + + assertNegative_failureMessage: (ut) -> + probe = makeProbe! + pcall UnitTest.assertNegative, probe, 5 -- 5 isn't negative → the assertion fails + ut\assertEquals probe.captured.args[1], "negative" + ut\assertEquals probe.captured.args[3], 5 -- the value, now passed for the template's %d + + -- run(): regression for the crash when a test class's setup fails (the -1 sentinel was iterated). + + setupFailureDoesNotCrashRun: (ut) -> + suite = UnitTestSuite "test.regression.setupCrash", { + Failing: { + _setup: -> error "intentional setup failure" + neverRuns: (t) -> t\assertTrue true + } + } + silence suite + ok, success = pcall -> suite\run! + ut\assertTrue ok -- previously raised on `for … in *(-1)` + ut\assertFalse success -- the failed setup is reported as a suite failure + + -- ut\skip: a test that skips itself is marked skipped (not failed), aborts the rest of its body, + -- and is reported as skipped with its reason; the suite run still succeeds. + skip_marksSkippedAbortsAndReports: (ut) -> + suite = UnitTestSuite "test.regression.skip", { + Skipping: { + skips: (t) -> + t\skip "unmet precondition" + t\assertTrue false -- unreachable: skip aborts the body before this would fail + passes: (t) -> t\assertTrue true + } + } + silence suite + ok, success = pcall -> suite\run! + ut\assertTrue ok + ut\assertTrue success -- a skip is not a failure + + byName = {t.name, t for t in *suite.classes[1].tests} + ut\assertTrue byName.skips.skipped + ut\assertEquals byName.skips.skipReason, "unmet precondition" + ut\assertFalse byName.passes.skipped + + summary = suite\toCtrf!.results.summary + ut\assertEquals summary.skipped, 1 + ut\assertEquals summary.passed, 1 + ut\assertEquals summary.failed, 0 + + -- a test absent from a class's _order still runs, appended (name-sorted) after the listed ones: + -- _order sets the run order, not which tests run. + runsTestsMissingFromOrder: (ut) -> + ran = {} + suite = UnitTestSuite "test.regression.orderMembership", { + Partial: { + _order: {"bbb", "aaa"} -- non-alphabetical, and deliberately omits "ccc" + aaa: (t) -> ran[#ran+1] = "aaa" + bbb: (t) -> ran[#ran+1] = "bbb" + ccc: (t) -> ran[#ran+1] = "ccc" + } + } + silence suite + suite\run! + ut\assertEquals ran, {"bbb", "aaa", "ccc"} -- listed order first, then the unlisted test appended + + -- assertItemsEqual/assertItemsAre: regressions for the failure message passing the keys + -- sub-phrase as the whole template, and the expected arg being type-checked against `actual` + + assertItemsEqual_passesAndFailureMessage: (ut) -> + probe = makeProbe! + ok = pcall UnitTest.assertItemsEqual, probe, {1, 2, 3}, {3, 2, 1} + ut\assertTrue ok + probe = makeProbe! + ok = pcall UnitTest.assertItemsEqual, probe, {1, 2}, {1, 3} + ut\assertFalse ok + ut\assertEquals probe.captured.msg, UnitTest.msgs.assert.itemsEqual + ut\assertEquals probe.captured.args[1], "equal" + ut\assertEquals probe.captured.args[2], UnitTest.msgs.assert.itemsEqualNumericKeys + + assertItemsEqual_typeChecksExpectedArg: (ut) -> + probe = makeProbe! + ok = pcall UnitTest.assertItemsEqual, probe, {1}, "not a table" + ut\assertFalse ok + ut\assertEquals probe.captured.msg, UnitTest.msgs.assert.checkArgTypes + + assertItemsAre_comparesByReference: (ut) -> + shared = {} + probe = makeProbe! + ok = pcall UnitTest.assertItemsAre, probe, {shared}, {shared} + ut\assertTrue ok + probe = makeProbe! + ok = pcall UnitTest.assertItemsAre, probe, {{}}, {{}} -- equal items, but not identical + ut\assertFalse ok + ut\assertEquals probe.captured.args[1], "identical" + + -- assertContinuous: regression for counting integer values instead of keys + + assertContinuous_checksKeysNotValues: (ut) -> + probe = makeProbe! + ok = pcall UnitTest.assertContinuous, probe, {"a", "b", "c"} -- continuous keys, no numeric values + ut\assertTrue ok + sparse = {"a", "b"} + sparse[4] = "d" + probe = makeProbe! + ok = pcall UnitTest.assertContinuous, probe, sparse + ut\assertFalse ok + + -- assertNotAlmostEquals: regression for printing the almostEquals message + + assertNotAlmostEquals_failureMessage: (ut) -> + probe = makeProbe! + ok = pcall UnitTest.assertNotAlmostEquals, probe, 1.0, 1.0 + 1e-10 + ut\assertFalse ok + ut\assertEquals probe.captured.msg, UnitTest.msgs.assert.notAlmostEquals + + -- assertError: regression for over-counting a non-raising function's returned values + + assertError_countsReturnedValues: (ut) -> + returnsTwo = -> 1, 2 + probe = makeProbe! + ok = pcall UnitTest.assertError, probe, returnsTwo + ut\assertFalse ok + ut\assertEquals probe.captured.args[1], 2 + probe = makeProbe! + err = UnitTest.assertError probe, -> error "boom" + ut\assertContains err, "boom" + + -- ut\stub in a _setup: regression for UnitTestSetup never initializing @stubs; setup stubs + -- are class-scoped and restored once the class (including teardown) has finished + + setupCanStubAndRestoresAfterClass: (ut) -> + target = {value: -> "real"} + sawStubInTeardown = nil + suite = UnitTestSuite "test.regression.setupStub", { + Stubbing: { + _setup: (s) -> + (s\stub target, "value")\returns "stubbed" + true + _teardown: -> sawStubInTeardown = target.value! == "stubbed" + usesStub: (t) -> t\assertEquals target.value!, "stubbed" + } + } + silence suite + ok, success = pcall -> suite\run! + ut\assertTrue ok + ut\assertTrue success + ut\assertTrue sawStubInTeardown -- still stubbed while the teardown ran + ut\assertEquals target.value!, "real" -- restored once the class finished + + -- suite run(true): regression for the abort path leaving endTime/success unset (breaking the + -- CTRF summary) and referencing a nonexistent message key + + abortOnFail_setsEndTimeAndSuccess: (ut) -> + suite = UnitTestSuite "test.regression.abort", { + Failing: { + fails: (t) -> t\assertTrue false + } + } + silence suite + ok, success = pcall -> suite\run true + ut\assertTrue ok + ut\assertFalse success + ut\assertNotNil suite.endTime + ut\assertFalse suite.success + } diff --git a/modules/l0/DependencyControl/test/UpdateFeed.moon b/modules/l0/DependencyControl/test/UpdateFeed.moon new file mode 100644 index 0000000..d1199c3 --- /dev/null +++ b/modules/l0/DependencyControl/test/UpdateFeed.moon @@ -0,0 +1,763 @@ +-- UpdateFeed tests: feed data access, script record retrieval, file deployment, +-- and feed refresh. +-- Called from Tests.moon as: (require "...test.UpdateFeed") basePath, DepCtrl +(basePath, DepCtrl) -> + Common = require "l0.DependencyControl.Common" + FileOps = require "l0.DependencyControl.FileOps" + FileCache = require "l0.DependencyControl.FileCache" + UpdateFeed = require "l0.DependencyControl.UpdateFeed" + {:stubSelf} = require "l0.DependencyControl.test.helpers.stub-helpers" + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + + -- Builds a stub feed around unexpanded data for driving expand directly. + makeExpandFeed = (unexpandedData, feedDir = basePath) -> + unexpandedData.macros or= {} + unexpandedData.modules or= {} + unexpandedData.knownFeeds or= {} + stubSelf UpdateFeed, { + _url: "https://example.com/f.json", :unexpandedData, :feedDir, + fileName: FileOps.joinPath(feedDir, "feed.json"), + __class: UpdateFeed, logger: DepCtrl.logger + } + + normalizePath = (path) -> path\gsub "[/\\]", "/" + + { + _description: "Tests for UpdateFeed feed data access, script record retrieval, and file deployment." + + 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 + + -- 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" + + -- getProviders: virtual-package resolution lookup over the feed's module section + + getProviders_findsByBareAlias: (ut) -> + feed = { + data: {modules: { + "l0.dkjson": {name: "dkjson", channels: {release: {default: true, version: "2.10.0", files: {}, provides: {"json", "dkjson"}}}} + "l0.Other": {name: "Other", channels: {release: {default: true, version: "1.0.0", files: {}}}} + }, macros: {}}, + logger: DepCtrl.logger, __class: UpdateFeed + } + providers = UpdateFeed.getProviders feed, "json" + ut\assertEquals #providers, 1 + ut\assertEquals providers[1].namespace, "l0.dkjson" + ut\assertEquals providers[1].version, "2.10.0" + + getProviders_objectAliasEntries: (ut) -> + feed = { + data: {modules: { + "l0.prov": {name: "P", channels: {release: {default: true, version: "1.2.3", files: {}, provides: {{name: "yaml"}}}}} + }, macros: {}}, + logger: DepCtrl.logger, __class: UpdateFeed + } + providers = UpdateFeed.getProviders feed, "yaml" + ut\assertEquals #providers, 1 + ut\assertEquals providers[1].namespace, "l0.prov" + + getProviders_noMatchReturnsEmpty: (ut) -> + feed = { + data: {modules: { + "l0.dkjson": {name: "dkjson", channels: {release: {default: true, version: "2.10.0", files: {}, provides: {"json"}}}} + }, macros: {}}, + logger: DepCtrl.logger, __class: UpdateFeed + } + ut\assertEquals #UpdateFeed.getProviders(feed, "xml"), 0 + + getProviders_ignoresModulesWithoutProvides: (ut) -> + feed = { + data: {modules: { + "l0.A": {name: "A", channels: {release: {default: true, version: "1.0.0", files: {}}}} + }, macros: {}}, + logger: DepCtrl.logger, __class: UpdateFeed + } + ut\assertEquals #UpdateFeed.getProviders(feed, "json"), 0 + + getProviders_noModulesSection: (ut) -> + feed = {data: {macros: {}}, logger: DepCtrl.logger, __class: UpdateFeed} + ut\assertEquals #UpdateFeed.getProviders(feed, "json"), 0 + + -- __normalizeModuleAliases: expands bare strings to ModuleAlias tables and preserves table fields + + normalizeModuleAliases_bareStringsToTables: (ut) -> + result = UpdateFeed\__normalizeModuleAliases {"json", "dkjson"} + ut\assertEquals #result, 2 + ut\assertEquals result[1].name, "json" + ut\assertEquals result[2].name, "dkjson" + + normalizeModuleAliases_preservesFields: (ut) -> + input = {{name: "json", version: "1.2.0"}} + result = UpdateFeed\__normalizeModuleAliases input + ut\assertEquals result[1].name, "json" + ut\assertEquals result[1].version, "1.2.0" + sameRef = result[1] == input[1] + ut\assertFalse sameRef -- copied, not the caller's table + + normalizeModuleAliases_dropsNonSchemaFields: (ut) -> + result = UpdateFeed\__normalizeModuleAliases {{name: "json", version: "1.0.0", optional: true, bogus: "x"}} + ut\assertEquals result[1].name, "json" + ut\assertEquals result[1].version, "1.0.0" + ut\assertNil result[1].optional + ut\assertNil result[1].bogus + + normalizeModuleAliases_nilAndEmpty: (ut) -> + ut\assertEquals #UpdateFeed\__normalizeModuleAliases(nil), 0 + ut\assertEquals #UpdateFeed\__normalizeModuleAliases({}), 0 + + -- 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" + + -- expand rebuilds @data from the unexpanded data each call and never mutates that (possibly shared) source + expand_rebuildsFromUnexpandedDataWithoutMutatingIt: (ut) -> + unexpandedData = {name: "TestFeed", description: "made for @{feedName}", macros: {}, modules: {}, knownFeeds: {}} + feed = stubSelf UpdateFeed, { + _url: "https://example.com/f.json", :unexpandedData, __class: UpdateFeed, logger: DepCtrl.logger + } + + data = UpdateFeed.expand feed + ut\assertEquals data.description, "made for TestFeed" -- template expanded in the working copy + ut\assertEquals unexpandedData.description, "made for @{feedName}" -- pristine source left untouched + ut\assertFalse data == unexpandedData -- @data is a fresh copy, not the source + + again = UpdateFeed.expand feed -- a second expand rebuilds from the source + ut\assertFalse again == data -- a new working copy each call + ut\assertEquals unexpandedData.description, "made for @{feedName}" -- source still pristine + + -- a fileBaseUrls map collapses to the entry matching each file's type, with @{fileName} baked in + expand_fileBaseUrlsCollapsePerType: (ut) -> + feed = makeExpandFeed { + name: "F" + fileBaseUrl: "https://x.test/" + fileBaseUrls: { + script: "@{fileBaseUrl}v@{version}/@{scriptTypeSection}/@{namespacePath}@{fileName}" + test: "@{fileBaseUrl}v@{version}/@{scriptTypeSection}/@{namespacePath}/test@{fileName}" + } + modules: { + "l0.NS": {name: "NS", channels: {release: {version: "1.2.3", files: { + {name: ".moon", url: "@{fileBaseUrl}"} + {name: "/Sub.moon", url: "@{fileBaseUrl}"} + {name: ".moon", url: "@{fileBaseUrl}", type: "test"} + }}}} + } + } + files = UpdateFeed.expand(feed).modules["l0.NS"].channels.release.files + ut\assertEquals files[1].url, "https://x.test/v1.2.3/modules/l0/NS.moon" + ut\assertEquals files[2].url, "https://x.test/v1.2.3/modules/l0/NS/Sub.moon" + ut\assertEquals files[3].url, "https://x.test/v1.2.3/modules/l0/NS/test.moon" + + -- a file type without a fileBaseUrls entry keeps the scalar fileBaseUrl as its base + expand_fileBaseUrlsFallbackToScalar: (ut) -> + feed = makeExpandFeed { + name: "F" + fileBaseUrl: "https://x.test/raw/" + fileBaseUrls: { + test: "@{fileBaseUrl}@{namespacePath}/test@{fileName}" + } + modules: { + "l0.NS": {name: "NS", channels: {release: {version: "1.0.0", files: { + {name: ".moon", url: "@{fileBaseUrl}@{fileName}"} + {name: ".moon", url: "@{fileBaseUrl}", type: "test"} + }}}} + } + } + files = UpdateFeed.expand(feed).modules["l0.NS"].channels.release.files + ut\assertEquals files[1].url, "https://x.test/raw/.moon" + ut\assertEquals files[2].url, "https://x.test/raw/l0/NS/test.moon" + + -- a rolling map set on a section container applies to that section only + expand_sectionScopedOverride: (ut) -> + feed = makeExpandFeed { + name: "F" + fileBaseUrl: "https://x.test/" + fileBaseUrls: { + script: "@{fileBaseUrl}@{scriptTypeSection}/@{namespacePath}@{fileName}" + } + macros: { + fileBaseUrls: { + script: "@{fileBaseUrl}@{scriptTypeSection}/@{namespace}@{fileName}" + } + "l0.Macro.NS": {name: "M", channels: {release: {version: "1.0.0", files: { + {name: ".moon", url: "@{fileBaseUrl}"} + }}}} + } + modules: { + "l0.NS": {name: "NS", channels: {release: {version: "1.0.0", files: { + {name: ".moon", url: "@{fileBaseUrl}"} + }}}} + } + } + data = UpdateFeed.expand feed + ut\assertEquals data.macros["l0.Macro.NS"].channels.release.files[1].url, "https://x.test/macros/l0.Macro.NS.moon" + ut\assertEquals data.modules["l0.NS"].channels.release.files[1].url, "https://x.test/modules/l0/NS.moon" + + expand_scriptTypeVariables: (ut) -> + feed = makeExpandFeed { + name: "F" + macros: { + "l0.Macro.NS": {name: "M", url: "https://x.test/@{scriptType}/@{scriptTypeSection}", channels: {release: {version: "1.0.0", files: {}}}} + } + modules: { + "l0.NS": {name: "NS", url: "https://x.test/@{scriptType}/@{scriptTypeSection}", channels: {release: {version: "1.0.0", files: {}}}} + } + } + data = UpdateFeed.expand feed + ut\assertEquals data.macros["l0.Macro.NS"].url, "https://x.test/#{Common.ScriptType.Automation}/macros" + ut\assertEquals data.modules["l0.NS"].url, "https://x.test/#{Common.ScriptType.Module}/modules" + + -- vars entries become variables; a table-valued one serves @{name:key} lookups whose key + -- part may itself be a variable that only comes into scope at channel depth + expand_authorVarsAndComputedKeys: (ut) -> + feed = makeExpandFeed { + name: "F" + vars: { + host: "https://cdn.test" + tagSuffix: {alpha: "-alpha", release: ""} + } + fileBaseUrl: "@{host}/dl/" + modules: { + "l0.NS": {name: "NS", channels: { + alpha: {version: "1.1.0", files: { + {name: ".moon", url: "@{fileBaseUrl}v@{version}@{tagSuffix:@{channel}}@{fileName}"} + }} + release: {version: "1.0.0", files: { + {name: ".moon", url: "@{fileBaseUrl}v@{version}@{tagSuffix:@{channel}}@{fileName}"} + }} + }} + } + } + channels = UpdateFeed.expand(feed).modules["l0.NS"].channels + ut\assertEquals channels.alpha.files[1].url, "https://cdn.test/dl/v1.1.0-alpha.moon" + ut\assertEquals channels.release.files[1].url, "https://cdn.test/dl/v1.0.0.moon" + + -- a collapsed localFileBasePaths entry is the complete path; an unmapped type appends the + -- file name to the scalar localFileBasePath (default "./") + expand_localFileBasePathsResolveFullPaths: (ut) -> + feed = makeExpandFeed { + name: "F" + localFileBasePaths: { + script: "@{localFileBasePath}@{scriptTypeSection}/@{namespacePath}@{fileName}" + } + modules: { + "l0.NS": {name: "NS", channels: {release: {version: "1.0.0", files: { + {name: ".moon"} + {name: "NS.moon", type: "test"} + }}}} + } + } + files = UpdateFeed.expand(feed, UpdateFeed.ExpansionMode.Local).modules["l0.NS"].channels.release.files + ut\assertEquals normalizePath(files[1].localFilePath), normalizePath "#{basePath}/modules/l0/NS.moon" + ut\assertEquals normalizePath(files[2].localFilePath), normalizePath "#{basePath}/NS.moon" + + -- a v0.3.0-style feed (scalar fileBaseUrl, explicit @{fileBaseUrl}@{fileName} urls) expands unchanged + expand_legacyScalarBases: (ut) -> + feed = makeExpandFeed { + name: "F" + fileBaseUrl: "https://x.test/@{channel}/@{namespace}" + modules: { + "l0.NS": {name: "NS", channels: {release: {version: "1.0.0", files: { + {name: ".moon", url: "@{fileBaseUrl}@{fileName}"} + }}}} + } + } + data = UpdateFeed.expand feed + ut\assertEquals data.modules["l0.NS"].channels.release.files[1].url, "https://x.test/release/l0.NS.moon" + + -- findUnlistedFiles inverts the per-type local path templates and reports on-disk files the + -- channel doesn't list; a file matching several types goes to the longest-prefix template, + -- and delete-flagged entries still count as listed + findUnlistedFiles_discoversUnlistedFiles: (ut) -> + root = FileOps.joinPath basePath, "discover1" + FileOps.mkdir FileOps.joinPath(root, "modules", "l0", "NS", "test"), false, true + FileOps.writeFile FileOps.joinPath(root, "modules", "l0", "NS.moon"), "-- main", true + FileOps.writeFile FileOps.joinPath(root, "modules", "l0", "NS", "New.moon"), "-- new", true + FileOps.writeFile FileOps.joinPath(root, "modules", "l0", "NS", "Del.moon"), "-- resurrected", true + FileOps.writeFile FileOps.joinPath(root, "modules", "l0", "NS", "test.moon"), "-- main test", true + FileOps.writeFile FileOps.joinPath(root, "modules", "l0", "NS", "test", "New.moon"), "-- new test", true + feed = makeExpandFeed { + name: "F" + fileBaseUrl: "https://x.test/" + fileBaseUrls: {script: "@{fileBaseUrl}@{namespacePath}@{fileName}"} + localFileBasePaths: { + script: "@{localFileBasePath}modules/@{namespacePath}@{fileName}" + test: "@{localFileBasePath}modules/@{namespacePath}/test@{fileName}" + } + modules: { + "l0.NS": {name: "NS", channels: {release: {version: "1.0.0", default: true, files: { + {name: ".moon", url: "@{fileBaseUrl}"} + {name: ".moon", url: "@{fileBaseUrl}", type: "test"} + {name: "/Del.moon", url: "@{fileBaseUrl}", delete: true} + }}}} + } + }, root + UpdateFeed.expand feed, UpdateFeed.ExpansionMode.Local + result = UpdateFeed.findUnlistedFiles feed + ut\assertEquals #result, 2 + ut\assertEquals result[1].name, "/New.moon" + ut\assertNil result[1].type + ut\assertEquals result[1].url, "@{fileBaseUrl}" -- script type has a fileBaseUrls entry + ut\assertEquals result[1].channel, "release" + ut\assertContains result[1].localFilePath, "New.moon" + ut\assertEquals result[2].name, "/New.moon" + ut\assertEquals result[2].type, "test" + ut\assertEquals result[2].url, "@{fileBaseUrl}@{fileName}" -- no fileBaseUrls entry for tests + + -- a local path template with an unexpanded variable besides @{fileName} can't be inverted + findUnlistedFiles_skipsUninvertibleTemplates: (ut) -> + root = FileOps.joinPath basePath, "discover2" + FileOps.mkdir FileOps.joinPath(root, "src"), false, true + FileOps.writeFile FileOps.joinPath(root, "src", "Stray.moon"), "-- stray", true + feed = makeExpandFeed { + name: "F" + localFileBasePaths: {script: "@{localFileBasePath}src/@{undeclared}@{fileName}"} + modules: { + "l0.NS": {name: "NS", channels: {release: {version: "1.0.0", default: true, files: {}}}} + } + }, root + UpdateFeed.expand feed, UpdateFeed.ExpansionMode.Local + result = UpdateFeed.findUnlistedFiles feed + ut\assertEquals #result, 0 + + -- updateFeed with addFiles appends discovered files to the raw channel, hashed and typed + updateFeed_addFilesAppendsEntries: (ut) -> + root = FileOps.joinPath basePath, "discover3" + FileOps.mkdir FileOps.joinPath(root, "modules", "l0", "NS"), false, true + FileOps.writeFile FileOps.joinPath(root, "modules", "l0", "NS.moon"), "-- main", true + FileOps.writeFile FileOps.joinPath(root, "modules", "l0", "NS", "New.moon"), "-- new", true + feedPath = FileOps.joinPath root, "feed.json" + FileOps.writeFile feedPath, [[{ + "dependencyControlFeedFormatVersion": "0.4.0", + "name": "T", + "fileBaseUrl": "https://x.test/", + "fileBaseUrls": {"script": "@{fileBaseUrl}@{namespacePath}@{fileName}"}, + "localFileBasePaths": {"script": "@{localFileBasePath}modules/@{namespacePath}@{fileName}"}, + "modules": {"l0.NS": {"name": "NS", "author": "a", "channels": {"release": {"version": "1.0.0", "default": true, + "files": [{"name": ".moon", "url": "@{fileBaseUrl}", "sha1": "0000000000000000000000000000000000000000"}]}}}} + }]], true + feed = UpdateFeed nil, false, feedPath + (ut\stub feed, "__refreshVersionRecord")\returns false + stats = feed\updateFeed {addFiles: true, outPath: false} + ut\assertTable stats + files = feed.rawFeedData.modules["l0.NS"].channels.release.files + ut\assertEquals #files, 2 + ut\assertEquals files[2].name, "/New.moon" + ut\assertEquals files[2].url, "@{fileBaseUrl}" + ut\assertMatches files[2].sha1, "^%x+$" + ut\assertEquals #files[2].sha1, 40 + result = stats.packages[1] + ut\assertTrue result.changed + ut\assertEquals result.addedFiles[1].name, "/New.moon" + ut\assertEquals stats.changed, 1 + + -- 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 is attached by `expand` in local mode, + -- 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 + + -- a file marked for deletion is removed from the dist (when present), not deployed + deployFiles_removesDeleted: (ut) -> + feed = { + data: {modules: {}, macros: {}}, + feedDir: basePath, logger: DepCtrl.logger, __class: UpdateFeed + } + dstPath = "#{basePath}/dst/Old.moon" + fakeFile = setmetatable {}, {__index: (_, k) -> + if k == "delete" then true + elseif k == "name" then "Old.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 + (ut\stub UpdateFeed, "getFileDeployPath")\returns dstPath + (ut\stub FILEOPS_MODULE_NAME, "exists")\returns true + removeStub = (ut\stub FILEOPS_MODULE_NAME, "remove")\returns true + copyStub = (ut\stub FILEOPS_MODULE_NAME, "copy")\returns true + fileCount, errCount = UpdateFeed.deployFiles feed, basePath + ut\assertEquals fileCount, 0 + ut\assertEquals errCount, 0 + removeStub\assertCalledOnce! + copyStub\assertNotCalled! + + -- a file marked for deletion whose target isn't in the dist is a clean no-op + deployFiles_deleteMissingIsNoOp: (ut) -> + feed = { + data: {modules: {}, macros: {}}, + feedDir: basePath, logger: DepCtrl.logger, __class: UpdateFeed + } + fakeFile = setmetatable {}, {__index: (_, k) -> + if k == "delete" then true + elseif k == "name" then "Old.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 + (ut\stub UpdateFeed, "getFileDeployPath")\returns "#{basePath}/dst/Old.moon" + (ut\stub FILEOPS_MODULE_NAME, "exists")\returns false + removeStub = (ut\stub FILEOPS_MODULE_NAME, "remove")\returns true + fileCount, errCount = UpdateFeed.deployFiles feed, basePath + ut\assertEquals fileCount, 0 + ut\assertEquals errCount, 0 + removeStub\assertNotCalled! + + -- 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 + + -- a fresh on-disk snapshot is served straight from the cache, never touching the network + ensureLoaded_readsFreshDiskCache: (ut) -> + cacheDir = FileOps.joinPath basePath, "uf-cache-fresh" + url = "https://example.com/fresh.json" + cache = FileCache cacheDir, "test", "feeds", {deserialize: UpdateFeed.deserialize} + cache\put url, '{"name":"FreshCache"}', "FreshCache" -- default lifetime → fresh right after writing + + feed = stubSelf UpdateFeed, { + _url: url, url: url, __class: UpdateFeed, logger: DepCtrl.logger + config: {cache: cache} + } + data = UpdateFeed.ensureLoaded feed + ut\assertEquals data.name, "FreshCache" + ut\assertNil feed.stale + + -- when the fetch fails and only a stale snapshot exists, ensureLoaded serves it and flags staleness + ensureLoaded_fallsBackToStaleCacheOffline: (ut) -> + cacheDir = FileOps.joinPath basePath, "uf-cache-stale" + url = "https://example.com/stale.json" + cache = FileCache cacheDir, "test", "feeds", {deserialize: UpdateFeed.deserialize} + cache\put url, '{"name":"StaleCache"}', "StaleCache", 0 -- expiresAfter 0 → immediately stale + + feed = stubSelf UpdateFeed, { + _url: url, url: url, __class: UpdateFeed, logger: DepCtrl.logger + config: {cache: cache} -- stale entry ⇒ attempts a fetch first + fetch: (...) -> nil, "network down" -- which fails, forcing the offline fallback + } + data = UpdateFeed.ensureLoaded feed + ut\assertEquals data.name, "StaleCache" + ut\assertTrue feed.stale + ut\assertNotNil feed.lastFetchedAt + + -- __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: { + "getKnownFeeds_noData", "getKnownFeeds_withData", + "getScript_invalidType", "getScript_missing", "getScript_found", + "getMacro_usesAutomationType", "getModule_usesModuleType", + "getModuleVersion_defaultChannel", "getModuleVersion_fallback", "getModuleVersion_missing", + "getProviders_findsByBareAlias", "getProviders_objectAliasEntries", "getProviders_noMatchReturnsEmpty", + "getProviders_ignoresModulesWithoutProvides", "getProviders_noModulesSection", + "normalizeModuleAliases_bareStringsToTables", "normalizeModuleAliases_preservesFields", + "normalizeModuleAliases_dropsNonSchemaFields", "normalizeModuleAliases_nilAndEmpty", + "getFileDeployPath_module", "getFileDeployPath_test", + "expand_rebuildsFromUnexpandedDataWithoutMutatingIt", + "expand_fileBaseUrlsCollapsePerType", "expand_fileBaseUrlsFallbackToScalar", + "expand_sectionScopedOverride", "expand_scriptTypeVariables", + "expand_authorVarsAndComputedKeys", "expand_localFileBasePathsResolveFullPaths", + "expand_legacyScalarBases", + "findUnlistedFiles_discoversUnlistedFiles", "findUnlistedFiles_skipsUninvertibleTemplates", + "updateFeed_addFilesAppendsEntries", + "walkFiles_yieldsProxies", "walkFiles_passesThroughLocalFilePath", + "deployFiles_copiesToDist", "deployFiles_skipExistingNoClobber", + "deployFiles_countsMissingSource", "deployFiles_removesDeleted", "deployFiles_deleteMissingIsNoOp", + "ensureLoaded_localWithoutFileName_errors", "ensureLoaded_reusesMatchingExpansion", + "ensureLoaded_readsFreshDiskCache", "ensureLoaded_fallsBackToStaleCacheOffline", + "refreshFiles_updatesChangedSha", "refreshFiles_unchangedSha", "refreshFiles_missingFileFlagsDelete", + "refreshFiles_sha1FailureCollectsError", "refreshFiles_noLocalPathCollectsError", + "updatePackage_notInRaw", "updatePackage_collectsResultAndResetsReleased", + "updatePackage_collectsRefreshError" + } + } diff --git a/modules/l0/DependencyControl/test/UpdateTask.moon b/modules/l0/DependencyControl/test/UpdateTask.moon new file mode 100644 index 0000000..0c1a97a --- /dev/null +++ b/modules/l0/DependencyControl/test/UpdateTask.moon @@ -0,0 +1,1062 @@ +-- UpdateTask tests: extracted from the main test suite. +-- Called from test.moon as: (controls\requireTest "UpdateTask")! +() -> + Common = require "l0.DependencyControl.Common" + UpdateTask = require "l0.DependencyControl.UpdateTask" + UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + SemanticVersion = require "l0.DependencyControl.SemanticVersion" + FileOps = require "l0.DependencyControl.FileOps" + UpdateFeed = require "l0.DependencyControl.UpdateFeed" + Downloader = require "l0.DependencyControl.Downloader" + ModuleLoader = require "l0.DependencyControl.ModuleLoader" + FeedTrust = require "l0.DependencyControl.FeedTrust" + {:stubSelf, :makeNullLogger, :makeSeededFeedTrust} = require "l0.DependencyControl.test.helpers.stub-helpers" + + UpdateStatus = UpdateTask.UpdateStatus + ContextCeiling = UpdateTask.ContextCeiling + UpdateReason = UpdateTask.UpdateReason + SourceChoiceStickiness = UpdateTask.SourceChoiceStickiness + SourceFeedKind = UpdateTask.SourceFeedKind + FeedTrustDecision = UpdateTask.FeedTrustDecision + -- dialog button labels are module-private; the test suite reads them through the test-export seam + msgs = UnitTestSuite\getTestExports(UpdateTask).msgs + + -- A candidate as pooled in run(): a feed record (with the fields __selectCandidate reads — version + -- string, namespace, checkPlatform predicate, files) plus its trust band and feed URL. + -- opts: namespace, feedUrl, platform (false to fail platform), platforms (offered-platform list), files (set {} to exclude). + makeCandidate = (band, version, opts = {}) -> + { + trustBand: band + feedUrl: opts.feedUrl or "feed://test" + isDirect: opts.isDirect != false + providesVersion: opts.providesVersion + updateRecord: { + namespace: opts.namespace or "l0.cand" + :version + files: opts.files == nil and {{}} or opts.files + checkPlatform: -> opts.platform != false + platforms: opts.platforms + } + } + + -- A stub task self for __selectCandidate: it consults targetVersion, logger, record.feed (the + -- declared-feed tie-break) and record.namespace (the ambiguity log). The metatable lets __selectCandidate + -- resolve its self-call to __getCandidateRankVersion. + makeSelectTask = (targetVersion, opts = {}) -> + stubSelf UpdateTask, { + :targetVersion + record: {namespace: "json", feed: opts.declaredFeed} + logger: {log: (_, ...) -> opts.logged[#opts.logged + 1] = {...} if opts.logged} + __class: UpdateTask + } + + -- A stub task self for the currentSource helpers (__resolveRememberedFeedUrl, __matchRememberedCandidate, + -- __feedSourceOf, __persistSource). opts: feed (declared feed URL), userFeed, channel, targetVersion, + -- currentSource (an existing persisted record), modules (installed-module config for provider feed + -- lookup), onSave (called by record.config\save!). The metatable resolves the helpers' self-calls. + makeSourceTask = (opts = {}) -> + stubSelf UpdateTask, { + targetVersion: opts.targetVersion or 0 + channel: opts.channel + record: { + feed: opts.feed + config: { + c: {userFeed: opts.userFeed, currentSource: opts.currentSource} + save: (=> opts.onSave! if opts.onSave) + } + } + updater: {config: {getSectionHandler: (_, section) -> {c: opts.modules or {}}}} + __class: UpdateTask + } + + -- A stub task self for __shouldPrompt and the prompt methods. opts: reason (the task's UpdateReason, + -- for __shouldPrompt), trustedFeeds, blockedFeeds, onSave (called by config\save!). __promptTrustFeed routes + -- its trust/block through @updater.feedTrust, so the stub carries a real FeedTrust over the same config. + makeInteractiveTask = (opts = {}) -> + config = {c: {feeds: {trustedFeeds: opts.trustedFeeds, blockedFeeds: opts.blockedFeeds}, updates: {}}, save: (=> opts.onSave! if opts.onSave)} + feedTrust = makeSeededFeedTrust {:config} + stubSelf UpdateTask, { + reason: opts.reason + record: {name: "TestMod", namespace: "l0.testmod", virtual: true, scriptType: Common.ScriptType.Module} + logger: makeNullLogger! + updater: {:config, :feedTrust} + __class: UpdateTask + } + + -- resolve() drives all feed I/O through @__loadFeed/@checkFeed and all prompting through @__shouldPrompt + -- /@__promptSelectPackageSource/@__promptTrustFeed; makeResolveTask stubs those so a test can place exactly + -- the candidates each cascade tier should see and script the prompt outcomes. `feeds` maps a feedUrl to + -- { direct?: <directRec>, providers?: {<providerRec>,...} }; a feed absent from the map fails to load. + directRec = (spec) -> { + version: spec.version, namespace: spec.namespace or "json", activeChannel: spec.channel or "release" + files: spec.files == nil and {{}} or spec.files, checkPlatform: -> spec.platform != false + platforms: spec.platforms + } + providerRec = (spec) -> { + namespace: spec.namespace, provides: {{name: "json", version: spec.providesVersion}} + files: spec.files == nil and {{}} or spec.files, checkPlatform: -> spec.platform != false + } + + -- opts: declaredFeed, feeds, currentSource, userFeed, addFeeds, optional, virtual (default true), + -- targetVersion, version (installed), modules (for provider feed lookup), officialTrusted/officialBlocked, + -- config {extraFeeds, trustedFeeds, blockedFeeds, offerAllSources, *PromptThreshold}, allowPrompt (gates + -- both prompts), selectReturn {pick, stickiness} (nil pick = abort), trustReturn (a FeedTrustDecision). + makeResolveTask = (opts = {}) -> + cfg = opts.config or {} + calls = {select: 0, trust: 0} + updaterConfig = { + c: { + feeds: {extraFeeds: cfg.extraFeeds, trustedFeeds: cfg.trustedFeeds, blockedFeeds: cfg.blockedFeeds} + updates: { + offerAllSources: cfg.offerAllSources + packageChoicePromptThreshold: cfg.packageChoicePromptThreshold or ContextCeiling.UserRequested + feedTrustPromptThreshold: cfg.feedTrustPromptThreshold or ContextCeiling.UserRequested + } + } + getSectionHandler: (_, section) -> {c: opts.modules or {}} + } + -- a real FeedTrust seeded with the official sets (so it never loads the live DepCtrl feed) over the + -- updater config, exactly as Updater wires it + feedTrust = makeSeededFeedTrust { + config: updaterConfig + official: {trusted: opts.officialTrusted or {}, blocked: opts.officialBlocked or {}} + } + task = stubSelf UpdateTask, { + __class: UpdateTask + :calls + targetVersion: opts.targetVersion or 0 + optional: opts.optional + reason: opts.reason + channel: opts.channel + addFeeds: opts.addFeeds or {} + triedFeeds: {} + _feeds: opts.feeds or {} + record: { + feed: opts.declaredFeed + namespace: "json" + name: "json" + version: opts.version or 0 + virtual: opts.virtual != false + scriptType: Common.ScriptType.Module + config: {c: {userFeed: opts.userFeed, currentSource: opts.currentSource}} + } + updater: {renewLock: ->, :feedTrust, config: updaterConfig} + logger: makeNullLogger! + __loadFeed: (url) => + return nil, "feed not found: #{url}" unless @_feeds[url] + providers = @_feeds[url].providers or {} + {__url: url, getProviders: (=> providers)} + checkFeed: (feed) => + d = @_feeds[feed.__url].direct + return nil, nil unless d + return d, nil, SemanticVersion\toPacked d.version + __shouldPrompt: (threshold) => opts.allowPrompt and true or false + __promptSelectPackageSource: (choices, preselect, noLongerAvail) => + calls.select += 1 + unpack(opts.selectReturn or {}) + __promptTrustFeed: (selected) => + calls.trust += 1 + opts.trustReturn + } + task + + -- A stub task self for run() itself. Its fake __class carries the mockable download engine for + -- the internet check; __resolve returns a scripted resolution and the dispatch targets record their + -- calls in `calls`. The metatable resolves run()'s own __base methods (e.g. __logUpdateError). + -- opts: resolution; running, updated, virtual (default true), isUserPath, online (default true), + -- lockOk (default true), installedSatisfies (drives record\checkVersion), targetVersion; + -- performUpdateReturn {code, detail}, installProviderReturn {ref, code, detail}. + makeRunTask = (opts = {}) -> + calls = {} + stubSelf UpdateTask, { + __class: {__downloader: {isInternetConnected: (=> opts.online != false)}} + :calls + running: opts.running + updated: opts.updated + targetVersion: opts.targetVersion or 0 + record: { + name: "TestMod", namespace: "l0.TestMod", scriptType: Common.ScriptType.Module, version: 0 + virtual: opts.virtual != false + getEntryPointPath: (=> "entry/path", opts.isUserPath) + checkVersion: (=> opts.installedSatisfies and true or false) + } + updater: {acquireLock: (=> opts.lockOk != false, "otherHost")} + logger: makeNullLogger! + __resolve: => + calls.resolved = true + opts.resolution + __persistSource: (sel, st) => calls.persisted = {selected: sel, stickiness: st} + performUpdate: (update) => + calls.performUpdate = update + unpack(opts.performUpdateReturn or {1, "performed"}) + __installProvider: (rec, url) => + calls.installProvider = {:rec, :url} + unpack(opts.installProviderReturn or {}) + } + + -- A stub task self for __installProvider: its fake __class carries a mockable DependencyControl + -- constructor (here one that wraps its ctor args for inspection), and updater.require records its call + -- in `opts.capture` and returns opts.requireReturn. + makeProviderTask = (opts = {}) -> + cap = opts.capture or {} + stubSelf UpdateTask, { + __class: {__DependencyControl: opts.depCtrl} + addFeeds: opts.addFeeds or {} + targetVersion: opts.targetVersion or 0 + optional: opts.optional + reason: opts.reason + updater: { + require: (record, targetVersion, addFeeds, optional, channel, reason) => + cap.record, cap.addFeeds = record, addFeeds + cap.targetVersion, cap.optional, cap.reason = targetVersion, optional, reason + unpack(opts.requireReturn or {}) + } + } + + -- A stub task self for performUpdate: a non-virtual record (so the dummy-ref + requiredModules preamble + -- is skipped) and a fake __class carrying a stub download engine. FileOps/UpdateFeed statics are + -- stubbed per-test. opts: downloadStatus (the status each created download ends up in), addDownloadFails. + makePerformTask = (opts = {}) -> + engine = {downloads: {}} + engine.clear = (self) -> self.downloads = {} + engine.addDownload = (self, url, outfile, sha1) -> + return nil, "add failed: #{url}" if opts.addDownloadFails + d = {:url, :outfile, :sha1, error: "download boom" + status: opts.downloadStatus or Downloader.Download.Status.Finished} + self.downloads[#self.downloads + 1] = d + d + engine.await = (self, cb) -> cb nil, 1 + stubSelf UpdateTask, { + __class: {__downloader: engine, __DependencyControl: {__name: "DependencyControl"}} + running: false + updated: false + targetVersion: 0 + record: { + name: "TestMod", namespace: "l0.test", automationDir: "auto", version: 0 + scriptType: Common.ScriptType.Module, virtual: false + } + updater: {renewLock: (=>), config: {c: {updates: {}}}} + logger: makeNullLogger! + } + + { + _description: "Tests for UpdateTask: candidate selection/ranking, interactivity gating, the trust/choice prompts, feed-prefix matching, and installed-provider lookup." + + -- UpdateTask.__selectCandidate: ranks the pooled candidates by trust band, then version, then a + -- deterministic tie-break (declared feed, then namespace); returns the winner or nil if none is eligible. + + -- eligibility: release version must meet the target version + selectCandidate_filtersByVersion: (ut) -> + task = makeSelectTask SemanticVersion\toPacked "1.0.0" + chosen = UpdateTask.__selectCandidate task, {makeCandidate(2, "0.9.0", namespace: "l0.old"), makeCandidate(2, "1.5.0", namespace: "l0.ok")} + ut\assertNotNil chosen + ut\assertEquals chosen.updateRecord.namespace, "l0.ok" + + selectCandidate_noneSatisfiesVersion: (ut) -> + task = makeSelectTask SemanticVersion\toPacked "3.0.0" + ut\assertNil UpdateTask.__selectCandidate task, {makeCandidate(2, "1.0.0"), makeCandidate(2, "2.9.0")} + + -- eligibility: a candidate whose channel can't run on the current platform is skipped + selectCandidate_skipsUnsupportedPlatform: (ut) -> + task = makeSelectTask 0 + chosen = UpdateTask.__selectCandidate task, + {makeCandidate(2, "2.0.0", namespace: "l0.win", platform: false), makeCandidate(2, "1.0.0", namespace: "l0.any")} + ut\assertEquals chosen.updateRecord.namespace, "l0.any" + + -- eligibility: a candidate with no files to install is skipped + selectCandidate_skipsEmptyFiles: (ut) -> + task = makeSelectTask 0 + chosen = UpdateTask.__selectCandidate task, + {makeCandidate(2, "2.0.0", namespace: "l0.nofiles", files: {}), makeCandidate(2, "1.0.0", namespace: "l0.ok")} + ut\assertEquals chosen.updateRecord.namespace, "l0.ok" + + selectCandidate_noneEligible: (ut) -> + task = makeSelectTask 0 + ut\assertNil UpdateTask.__selectCandidate task, {makeCandidate(2, "2.0.0", platform: false)} + + -- a lower (more trusted) band wins even against a higher-version candidate in a higher band + selectCandidate_lowerBandBeatsHigherVersion: (ut) -> + task = makeSelectTask 0 + chosen = UpdateTask.__selectCandidate task, + {makeCandidate(4, "9.9.9", namespace: "l0.untrusted"), makeCandidate(1, "1.0.0", namespace: "l0.trusted")} + ut\assertEquals chosen.updateRecord.namespace, "l0.trusted" + ut\assertEquals chosen.trustBand, 1 + + selectCandidate_highestVersionWithinBand: (ut) -> + task = makeSelectTask 0 + chosen = UpdateTask.__selectCandidate task, {makeCandidate(2, "1.0.0", namespace: "l0.a"), makeCandidate(2, "2.0.0", namespace: "l0.b")} + ut\assertEquals chosen.updateRecord.namespace, "l0.b" + + -- same band + version: the candidate from the declared feed wins (even with a higher namespace) + selectCandidate_declaredFeedTiebreak: (ut) -> + task = makeSelectTask 0, declaredFeed: "feed://declared" + chosen = UpdateTask.__selectCandidate task, + {makeCandidate(2, "2.0.0", namespace: "l0.a", feedUrl: "feed://other"), makeCandidate(2, "2.0.0", namespace: "l0.z", feedUrl: "feed://declared")} + ut\assertEquals chosen.updateRecord.namespace, "l0.z" + + -- same band + version, neither declared: lexicographically lowest namespace wins; the tie is logged + selectCandidate_namespaceTiebreakLogged: (ut) -> + logged = {} + task = makeSelectTask 0, logged: logged + chosen = UpdateTask.__selectCandidate task, + {makeCandidate(3, "2.0.0", namespace: "l0.b"), makeCandidate(3, "2.0.0", namespace: "l0.a")} + ut\assertEquals chosen.updateRecord.namespace, "l0.a" + ut\assertTrue #logged > 0 + + selectCandidate_unambiguousNoLog: (ut) -> + logged = {} + task = makeSelectTask 0, logged: logged + chosen = UpdateTask.__selectCandidate task, {makeCandidate(1, "1.0.0", namespace: "l0.only")} + ut\assertEquals chosen.updateRecord.namespace, "l0.only" + ut\assertEquals #logged, 0 + + -- a provider is judged by the alias range it declares, not its own release version: the unrelated + -- release 9.9.9 is ignored, and ~1.2 still covers the 1.2.4 target + selectCandidate_providesVersionRangeSatisfies: (ut) -> + task = makeSelectTask SemanticVersion\toPacked "1.2.4" + chosen = UpdateTask.__selectCandidate task, {makeCandidate(3, "9.9.9", namespace: "l0.prov", isDirect: false, providesVersion: "~1.2")} + ut\assertNotNil chosen + ut\assertEquals chosen.updateRecord.namespace, "l0.prov" + + -- conversely, a high release version can't rescue a provider once its declared range no longer reaches the target + selectCandidate_providesVersionRangeRejects: (ut) -> + task = makeSelectTask SemanticVersion\toPacked "1.5.0" + ut\assertNil UpdateTask.__selectCandidate task, {makeCandidate(3, "9.9.9", namespace: "l0.prov", isDirect: false, providesVersion: "~1.2")} + + -- a target below the declared range stays satisfiable: the provider can still supply a version >= target + selectCandidate_providesVersionRangeAboveTarget: (ut) -> + task = makeSelectTask SemanticVersion\toPacked "1.0.0" + chosen = UpdateTask.__selectCandidate task, {makeCandidate(3, "1.0.0", namespace: "l0.prov", isDirect: false, providesVersion: "~1.2")} + ut\assertNotNil chosen + ut\assertEquals chosen.updateRecord.namespace, "l0.prov" + + -- a provider that declares no range stands in for any version + selectCandidate_providerNoRangeMatchesAny: (ut) -> + task = makeSelectTask SemanticVersion\toPacked "5.0.0" + chosen = UpdateTask.__selectCandidate task, {makeCandidate(3, "1.0.0", namespace: "l0.prov", isDirect: false)} + ut\assertNotNil chosen + ut\assertEquals chosen.updateRecord.namespace, "l0.prov" + + -- among providers, the release version doesn't drive rank: the wider declared range (higher covered + -- version) wins even though its provider has the lower release version + selectCandidate_providerRankedByRangeMaxNotRelease: (ut) -> + task = makeSelectTask SemanticVersion\toPacked "1.0.0" + chosen = UpdateTask.__selectCandidate task, + {makeCandidate(3, "9.9.9", namespace: "l0.a", isDirect: false, providesVersion: "^1"), + makeCandidate(3, "1.0.0", namespace: "l0.b", isDirect: false, providesVersion: "^2")} + ut\assertEquals chosen.updateRecord.namespace, "l0.b" + + -- __selectCandidate's 2nd return is the set of candidates tied with the winner (for the chooser) + selectCandidate_returnsTiedSet: (ut) -> + task = makeSelectTask 0 + _, tied = UpdateTask.__selectCandidate task, + {makeCandidate(3, "1.0.0", namespace: "l0.a"), makeCandidate(3, "1.0.0", namespace: "l0.b"), + makeCandidate(3, "1.0.0", namespace: "l0.c", platform: false)} + ut\assertEquals #tied, 2 -- the platform-ineligible one is excluded + + -- UpdateTask.isWithinContextCeiling: the shared context-ladder gate — each ceiling admits exactly the + -- contexts at or below its rung; off, unset, and unrecognized ceilings admit none + isWithinContextCeiling_ranksLadder: (ut) -> + ut\assertTrue UpdateTask.isWithinContextCeiling UpdateReason.UserRequested, ContextCeiling.UserRequested + ut\assertFalse UpdateTask.isWithinContextCeiling UpdateReason.DependencyResolution, ContextCeiling.UserRequested + ut\assertTrue UpdateTask.isWithinContextCeiling UpdateReason.AutoUpdate, ContextCeiling.AutoUpdate + ut\assertFalse UpdateTask.isWithinContextCeiling UpdateReason.UserRequested, ContextCeiling.Off + ut\assertFalse UpdateTask.isWithinContextCeiling UpdateReason.UserRequested, nil + ut\assertFalse UpdateTask.isWithinContextCeiling UpdateReason.UserRequested, "garbage" + + -- UpdateTask.__shouldPrompt: a task may prompt only when its reason is permitted by the given threshold + + shouldPrompt_withinThreshold: (ut) -> + ut\assertTrue UpdateTask.__shouldPrompt makeInteractiveTask({reason: UpdateReason.DependencyResolution}), ContextCeiling.DependencyResolution + ut\assertTrue UpdateTask.__shouldPrompt makeInteractiveTask({reason: UpdateReason.UserRequested}), ContextCeiling.AutoUpdate + + shouldPrompt_aboveThreshold: (ut) -> + ut\assertFalse UpdateTask.__shouldPrompt makeInteractiveTask({reason: UpdateReason.AutoUpdate}), ContextCeiling.UserRequested + + shouldPrompt_offNeverPrompts: (ut) -> + ut\assertFalse UpdateTask.__shouldPrompt makeInteractiveTask({reason: UpdateReason.UserRequested}), ContextCeiling.Off + + shouldPrompt_noReason: (ut) -> + ut\assertFalse UpdateTask.__shouldPrompt makeInteractiveTask({reason: nil}), ContextCeiling.AutoUpdate + + -- feedTrustPromptThreshold defaults to the auto-update ceiling, so an untrusted-feed prompt still shows + -- during a background (auto-update) install; a user-requested ceiling (see shouldPrompt_aboveThreshold) + -- would suppress it. + shouldPrompt_feedTrustDefaultAllowsAutoUpdate: (ut) -> + ut\assertEquals UpdateTask.defaultFeedTrustPromptThreshold, ContextCeiling.AutoUpdate + ut\assertTrue UpdateTask.__shouldPrompt makeInteractiveTask({reason: UpdateReason.AutoUpdate}), UpdateTask.defaultFeedTrustPromptThreshold + + -- UpdateTask.__promptTrustFeed: shows the dialog (callers gate it); "always" trusts the feed, "never" + -- blocks it, and a cancelled prompt returns nil. + + promptTrustFeed_trustOnce: (ut) -> + task = makeInteractiveTask! + ut\stub(aegisub.dialog, "display")\calls -> msgs.__promptTrustFeed.trustOnce + ut\assertEquals UpdateTask.__promptTrustFeed(task, {feedUrl: "feed://x"}), FeedTrustDecision.Once + + promptTrustFeed_cancelReturnsNil: (ut) -> + task = makeInteractiveTask! + ut\stub(aegisub.dialog, "display")\calls -> msgs.dialogCommon.cancel + ut\assertNil UpdateTask.__promptTrustFeed(task, {feedUrl: "feed://x"}) + + promptTrustFeed_trustAlwaysPersists: (ut) -> + saved = {} + task = makeInteractiveTask trustedFeeds: {}, onSave: -> saved[1] = true + ut\stub(aegisub.dialog, "display")\calls -> msgs.__promptTrustFeed.trustAlways + ut\assertEquals UpdateTask.__promptTrustFeed(task, {feedUrl: "feed://new"}), FeedTrustDecision.Always + ut\assertEquals task.updater.config.c.feeds.trustedFeeds[1], "feed://new" + ut\assertTrue saved[1] + + promptTrustFeed_neverBlocks: (ut) -> + saved = {} + task = makeInteractiveTask blockedFeeds: {}, onSave: -> saved[1] = true + ut\stub(aegisub.dialog, "display")\calls -> msgs.__promptTrustFeed.trustNever + ut\assertEquals UpdateTask.__promptTrustFeed(task, {feedUrl: "feed://bad"}), FeedTrustDecision.Never + ut\assertEquals task.updater.config.c.feeds.blockedFeeds[1], {url: "feed://bad", matchMode: "prefix"} + ut\assertTrue saved[1] + + -- UpdateTask.__promptSelectPackageSource: shows the dialog (callers gate it), returning the picked + -- candidate and the chosen stickiness; an "auto" pick keeps the winner and an "abort" returns nil. + + promptSelectPackageSource_picksSelection: (ut) -> + task = makeInteractiveTask! + winner = {feedUrl: "feed://a", updateRecord: {namespace: "l0.a", name: "A"}} + other = {feedUrl: "feed://b", updateRecord: {namespace: "l0.b", name: "B"}} + ut\stub(aegisub.dialog, "display")\calls -> msgs.promptSelectSource.retain, {choice: "B (feed://b)"} + chosen, stickiness = UpdateTask.__promptSelectPackageSource task, {winner, other}, winner + ut\assertEquals chosen, other + ut\assertEquals stickiness, SourceChoiceStickiness.Retain + + promptSelectPackageSource_autoKeepsWinner: (ut) -> + task = makeInteractiveTask! + winner = {feedUrl: "feed://a", updateRecord: {namespace: "l0.a", name: "A"}} + other = {feedUrl: "feed://b", updateRecord: {namespace: "l0.b", name: "B"}} + -- "Let DepCtrl Decide" ignores the dropdown and keeps the algorithm's pick + ut\stub(aegisub.dialog, "display")\calls -> msgs.promptSelectSource.auto, {choice: "B (feed://b)"} + chosen, stickiness = UpdateTask.__promptSelectPackageSource task, {winner, other}, winner + ut\assertEquals chosen, winner + ut\assertEquals stickiness, SourceChoiceStickiness.Auto + + promptSelectPackageSource_abortReturnsNil: (ut) -> + task = makeInteractiveTask! + winner = {feedUrl: "feed://a", updateRecord: {namespace: "l0.a", name: "A"}} + other = {feedUrl: "feed://b", updateRecord: {namespace: "l0.b", name: "B"}} + ut\stub(aegisub.dialog, "display")\calls -> msgs.promptSelectSource.abort, {} + ut\assertNil UpdateTask.__promptSelectPackageSource task, {winner, other}, winner + + -- UpdateTask.__resolveRememberedFeedUrl: derives the feed URL of a remembered source for every kind + -- but `other` (which stores it), so a remembered choice survives a feed-URL migration. + + resolveRememberedFeedUrl_selfDeclared: (ut) -> + task = makeSourceTask feed: "feed://declared" + ut\assertEquals UpdateTask.__resolveRememberedFeedUrl(task, {feedSource: SourceFeedKind.SelfDeclared}), "feed://declared" + + resolveRememberedFeedUrl_userFeed: (ut) -> + task = makeSourceTask userFeed: "feed://user" + ut\assertEquals UpdateTask.__resolveRememberedFeedUrl(task, {feedSource: SourceFeedKind.UserFeed}), "feed://user" + + resolveRememberedFeedUrl_other: (ut) -> + task = makeSourceTask! + ut\assertEquals UpdateTask.__resolveRememberedFeedUrl(task, {feedSource: SourceFeedKind.Other, feedUrl: "feed://third"}), "feed://third" + + resolveRememberedFeedUrl_provider: (ut) -> + task = makeSourceTask modules: {"l0.prov": {feed: "feed://prov"}} + ut\assertEquals UpdateTask.__resolveRememberedFeedUrl(task, {feedSource: SourceFeedKind.Provider, provider: {namespace: "l0.prov"}}), "feed://prov" + + resolveRememberedFeedUrl_providerMissing: (ut) -> + task = makeSourceTask modules: {} + ut\assertNil UpdateTask.__resolveRememberedFeedUrl task, {feedSource: SourceFeedKind.Provider, provider: {namespace: "l0.gone"}} + + -- UpdateTask.__matchRememberedCandidate: finds the pooled candidate corresponding to the remembered + -- source, but only when it's still eligible to satisfy the task. + + matchRememberedCandidate_direct: (ut) -> + task = makeSourceTask feed: "feed://declared" + candidates = { + makeCandidate(2, "1.0.0", feedUrl: "feed://other", namespace: "l0.x") + makeCandidate(1, "1.0.0", feedUrl: "feed://declared", namespace: "l0.x") + } + m = UpdateTask.__matchRememberedCandidate task, candidates, {feedSource: SourceFeedKind.SelfDeclared} + ut\assertNotNil m + ut\assertEquals m.feedUrl, "feed://declared" + + matchRememberedCandidate_provider: (ut) -> + task = makeSourceTask modules: {"l0.prov": {feed: "feed://prov"}} + candidates = {makeCandidate(3, "0.1.0", feedUrl: "feed://prov", isDirect: false, namespace: "l0.prov", providesVersion: "*")} + m = UpdateTask.__matchRememberedCandidate task, candidates, {feedSource: SourceFeedKind.Provider, provider: {namespace: "l0.prov"}} + ut\assertNotNil m + ut\assertFalse m.isDirect + + matchRememberedCandidate_ineligibleVersion: (ut) -> + task = makeSourceTask feed: "feed://declared", targetVersion: SemanticVersion\toPacked "5.0.0" + candidates = {makeCandidate(1, "1.0.0", feedUrl: "feed://declared")} + ut\assertNil UpdateTask.__matchRememberedCandidate task, candidates, {feedSource: SourceFeedKind.SelfDeclared} + + matchRememberedCandidate_noUrlMatch: (ut) -> + task = makeSourceTask feed: "feed://declared" + candidates = {makeCandidate(1, "1.0.0", feedUrl: "feed://elsewhere")} + ut\assertNil UpdateTask.__matchRememberedCandidate task, candidates, {feedSource: SourceFeedKind.SelfDeclared} + + -- UpdateTask.__feedSourceOf: classifies a chosen candidate's source kind for persistence. + + feedSourceOf_provider: (ut) -> + task = makeSourceTask feed: "feed://declared" + ut\assertEquals UpdateTask.__feedSourceOf(task, {isDirect: false, feedUrl: "feed://prov"}), SourceFeedKind.Provider + + feedSourceOf_selfDeclared: (ut) -> + task = makeSourceTask feed: "feed://declared" + ut\assertEquals UpdateTask.__feedSourceOf(task, {isDirect: true, feedUrl: "feed://declared"}), SourceFeedKind.SelfDeclared + + feedSourceOf_userFeed: (ut) -> + task = makeSourceTask feed: "feed://declared", userFeed: "feed://user" + ut\assertEquals UpdateTask.__feedSourceOf(task, {isDirect: true, feedUrl: "feed://user"}), SourceFeedKind.UserFeed + + feedSourceOf_other: (ut) -> + task = makeSourceTask feed: "feed://declared" + ut\assertEquals UpdateTask.__feedSourceOf(task, {isDirect: true, feedUrl: "feed://third"}), SourceFeedKind.Other + + -- UpdateTask.__persistSource: records the resolved source and stickiness, writing only when changed. + + persistSource_writesDirect: (ut) -> + saved = {} + task = makeSourceTask feed: "feed://declared", onSave: -> saved[1] = true + selected = {isDirect: true, feedUrl: "feed://declared", updateRecord: {namespace: "l0.x", activeChannel: "main"}} + UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Retain + cs = task.record.config.c.currentSource + ut\assertNotNil cs + ut\assertEquals cs.feedSource, SourceFeedKind.SelfDeclared + ut\assertEquals cs.stickiness, SourceChoiceStickiness.Retain + ut\assertEquals cs.channel, "main" + ut\assertTrue saved[1] + + persistSource_recordsProvider: (ut) -> + task = makeSourceTask feed: "feed://declared" + selected = {isDirect: false, feedUrl: "feed://prov", providesVersion: "~1.2", updateRecord: {namespace: "l0.prov", activeChannel: "main"}} + UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Pinned + cs = task.record.config.c.currentSource + ut\assertEquals cs.feedSource, SourceFeedKind.Provider + ut\assertEquals cs.provider.namespace, "l0.prov" + ut\assertEquals cs.provider.version, "~1.2" + + persistSource_storesFeedUrlForOther: (ut) -> + task = makeSourceTask feed: "feed://declared" + selected = {isDirect: true, feedUrl: "feed://third", updateRecord: {namespace: "l0.x", activeChannel: "main"}} + UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Once + ut\assertEquals task.record.config.c.currentSource.feedUrl, "feed://third" + + persistSource_skipsUnchanged: (ut) -> + saves = {n: 0} + existing = {feedSource: SourceFeedKind.SelfDeclared, channel: "main", stickiness: SourceChoiceStickiness.Retain} + task = makeSourceTask feed: "feed://declared", currentSource: existing, onSave: -> saves.n += 1 + selected = {isDirect: true, feedUrl: "feed://declared", updateRecord: {namespace: "l0.x", activeChannel: "main"}} + UpdateTask.__persistSource task, selected, SourceChoiceStickiness.Retain + ut\assertEquals saves.n, 0 + + -- UpdateTask.__resolve: walks the lazy trust-ranked feed cascade and the currentSource stickiness tree, + -- running prompts inline, and returns either a candidate to install or a terminal status code. It + -- performs no installs, so it's tested directly with feed I/O and prompts stubbed (see makeResolveTask). + + -- cascade: a DeclaredDirect (band 1) winner short-circuits the rest of the cascade — the trusted tier + -- is never even fetched (a higher-version candidate there is irrelevant). + resolve_cascadeShortCircuitsOnDeclaredDirect: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://decl": true} + config: {extraFeeds: {"feed://extra"}} + feeds: {"feed://decl": {direct: directRec version: "1.0.0"}, "feed://extra": {direct: directRec version: "9.9.9"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://decl" + ut\assertNil task.triedFeeds["feed://extra"] -- tier 2 was never reached + + -- cascade: an empty declared feed falls through to a user extra feed (trusted discovery, tier 2). + -- Guards that extraFeeds is read from the `feeds` config section, not `updates`. + resolve_fallsThroughToExtraFeed: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl" + config: {extraFeeds: {"feed://extra"}} + feeds: {"feed://decl": {}, "feed://extra": {direct: directRec version: "2.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://extra" + + -- cascade: an empty declared feed falls through to a trusted feed (TrustedDirect, band 2) + resolve_fallsThroughToTrustedDirect: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://trusted": true} + feeds: {"feed://decl": {}, "feed://trusted": {direct: directRec version: "2.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://trusted" + ut\assertEquals d.selectedSource.trustBand, 2 + + -- trust gate: a required dependency whose only source is an untrusted feed (band 4) fails with -16 + -- when no prompt is permitted + resolve_untrustedRequiredFailsWithoutPrompt: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl", addFeeds: {"feed://un"} + feeds: {"feed://decl": {}, "feed://un": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.UntrustedFeed + + -- trust gate: the same untrusted winner proceeds once the user approves it (Trust this time) + resolve_untrustedApprovedProceeds: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl", addFeeds: {"feed://un"}, allowPrompt: true, trustReturn: FeedTrustDecision.Once + feeds: {"feed://decl": {}, "feed://un": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://un" + ut\assertEquals task.calls.trust, 1 + + -- trust gate: an optional dependency from an untrusted feed is skipped (3) rather than failed + resolve_untrustedOptionalSkips: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl", addFeeds: {"feed://un"}, optional: true + feeds: {"feed://decl": {}, "feed://un": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.SkippedOptional + + -- no candidate: a required install with no source anywhere fails with -6 + resolve_noCandidateRequiredFails: (ut) -> + task = makeResolveTask {declaredFeed: "feed://decl", feeds: {"feed://decl": {}}} + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.NoSuitablePackage + + -- no candidate: an optional install with no source is skipped (3) + resolve_noCandidateOptionalSkips: (ut) -> + task = makeResolveTask {declaredFeed: "feed://decl", optional: true, feeds: {"feed://decl": {}}} + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.SkippedOptional + + -- platform shortfall: a candidate matches the required version but offers no build for the current + -- platform, so the failure detail names the offered platforms and the current one instead of implying + -- the version is installable + resolve_noPlatformBuildReportsPlatforms: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://decl": true} + feeds: {"feed://decl": {direct: directRec version: "1.0.0", platform: false, platforms: {"Windows-x64", "OSX-x64"}}} + } + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.NoSuitablePackage + ut\assertContains d.statusDetailMessage, "Windows-x64" + ut\assertContains d.statusDetailMessage, "OSX-x64" + ut\assertContains d.statusDetailMessage, Common.platform + + -- pinned: the remembered source is reused directly and the pin is preserved, no prompt + resolve_pinnedReuseProceeds: (ut) -> + cs = {feedSource: SourceFeedKind.SelfDeclared, channel: "release", stickiness: SourceChoiceStickiness.Pinned} + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://decl": true}, currentSource: cs + feeds: {"feed://decl": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://decl" + ut\assertEquals d.stickiness, SourceChoiceStickiness.Pinned + ut\assertEquals task.calls.select, 0 + + -- pinned: a required dependency whose pinned source has vanished aborts with -17 (no silent switch) + resolve_pinnedMissingRequiredAborts: (ut) -> + cs = {feedSource: SourceFeedKind.SelfDeclared, channel: "release", stickiness: SourceChoiceStickiness.Pinned} + task = makeResolveTask {declaredFeed: "feed://decl", currentSource: cs, feeds: {"feed://decl": {}}} + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.PinnedUnavailable + + -- pinned: the same vanished pin only skips (3) for an optional dependency + resolve_pinnedMissingOptionalSkips: (ut) -> + cs = {feedSource: SourceFeedKind.SelfDeclared, channel: "release", stickiness: SourceChoiceStickiness.Pinned} + task = makeResolveTask {declaredFeed: "feed://decl", optional: true, currentSource: cs, feeds: {"feed://decl": {}}} + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.SkippedOptional + + -- retain: a still-eligible remembered source is reused without prompting + resolve_retainReuseProceeds: (ut) -> + cs = {feedSource: SourceFeedKind.SelfDeclared, channel: "release", stickiness: SourceChoiceStickiness.Retain} + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://decl": true}, currentSource: cs + feeds: {"feed://decl": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://decl" + ut\assertEquals task.calls.select, 0 + + -- retain (soft): when the remembered pick is gone and the run is non-interactive, fall back to the + -- cascade's pick and downgrade the stickiness to Once + resolve_retainMissingNonInteractiveDowngrades: (ut) -> + cs = {feedSource: SourceFeedKind.Other, feedUrl: "feed://gone", channel: "release", stickiness: SourceChoiceStickiness.Retain} + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://decl": true}, currentSource: cs + feeds: {"feed://decl": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://decl" + ut\assertEquals d.stickiness, SourceChoiceStickiness.Once + ut\assertEquals task.calls.select, 0 + + -- retain (soft): when the remembered pick is gone and the run is interactive, re-prompt and take the + -- user's pick and stickiness + resolve_retainMissingInteractivePicks: (ut) -> + cs = {feedSource: SourceFeedKind.Other, feedUrl: "feed://gone", channel: "release", stickiness: SourceChoiceStickiness.Retain} + pick = {feedUrl: "feed://decl", trustBand: 1, isDirect: true, updateRecord: {namespace: "json", version: "1.0.0"}} + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://decl": true}, currentSource: cs + allowPrompt: true, selectReturn: {pick, SourceChoiceStickiness.Retain} + feeds: {"feed://decl": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource, pick + ut\assertEquals d.stickiness, SourceChoiceStickiness.Retain + ut\assertEquals task.calls.select, 1 + + -- the chooser aborts the update: a required dependency fails with -18 + resolve_choiceAbortRequiredFails: (ut) -> + cs = {feedSource: SourceFeedKind.Other, feedUrl: "feed://gone", channel: "release", stickiness: SourceChoiceStickiness.Retain} + task = makeResolveTask { + declaredFeed: "feed://decl", officialTrusted: {"feed://decl": true}, currentSource: cs + allowPrompt: true, selectReturn: {} + feeds: {"feed://decl": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.UserAborted + + -- auto: never prompts even when several equally-ranked candidates tie; takes the algorithm's pick + resolve_autoNeverPrompts: (ut) -> + cs = {feedSource: SourceFeedKind.SelfDeclared, channel: "release", stickiness: SourceChoiceStickiness.Auto} + task = makeResolveTask { + currentSource: cs, allowPrompt: true, officialTrusted: {"feed://a": true, "feed://b": true} + feeds: {"feed://a": {direct: directRec version: "1.0.0"}, "feed://b": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals task.calls.select, 0 + + -- offer-all-sources: with the global toggle on, the chooser fires whenever ≥2 candidates are eligible + resolve_offerAllSourcesPromptsOnMultiple: (ut) -> + pick = {feedUrl: "feed://b", trustBand: 2, isDirect: true, updateRecord: {namespace: "json", version: "1.0.0"}} + task = makeResolveTask { + allowPrompt: true, config: {offerAllSources: true} + officialTrusted: {"feed://a": true, "feed://b": true}, selectReturn: {pick, SourceChoiceStickiness.Once} + feeds: {"feed://a": {direct: directRec version: "1.0.0"}, "feed://b": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource, pick + ut\assertEquals task.calls.select, 1 + + -- blocked feed: a block-listed feed is skipped entirely (not even fetched, and despite being + -- trusted); with no other source the required install fails + resolve_blockedFeedSkipped: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://blocked", officialTrusted: {"feed://blocked": true} + config: {blockedFeeds: {{url: "feed://blocked"}}} + feeds: {"feed://blocked": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertFalse d.installRequired + ut\assertEquals d.statusCode, UpdateStatus.NoSuitablePackage + ut\assertNil task.triedFeeds["feed://blocked"] -- skipped before being fetched + + -- userFeed: an exclusive override feed is consulted in place of the declared-feed cascade + resolve_userFeedUsedExclusively: (ut) -> + task = makeResolveTask { + declaredFeed: "feed://decl", userFeed: "feed://user" + feeds: {"feed://decl": {direct: directRec version: "9.9.9"}, "feed://user": {direct: directRec version: "1.0.0"}} + } + d = UpdateTask.__resolve task + ut\assertTrue d.installRequired + ut\assertEquals d.selectedSource.feedUrl, "feed://user" + ut\assertNil task.triedFeeds["feed://decl"] -- the declared feed is never consulted + + -- run(): a direct-install resolution is persisted, then dispatched to performUpdate + run_dispatchesDirectInstall: (ut) -> + task = makeRunTask { + virtual: false -- so the post-resolve up-to-date check runs + resolution: { + installRequired: true, stickiness: SourceChoiceStickiness.Once, maxVersion: 0 + selectedSource: {isDirect: true, updateRecord: {version: "1.0.0"}} + } + } + code = UpdateTask.run task + ut\assertEquals code, UpdateStatus.Installed + ut\assertNotNil task.calls.persisted + ut\assertNotNil task.calls.performUpdate + ut\assertNil task.calls.installProvider + + -- run(): a provider (indirect) resolution is dispatched to installProvider, not performUpdate + run_dispatchesProviderInstall: (ut) -> + providerRef = {ref: true} + task = makeRunTask { + installProviderReturn: {providerRef} + resolution: { + installRequired: true, stickiness: SourceChoiceStickiness.Once, maxVersion: 0 + selectedSource: { + isDirect: false, feedUrl: "feed://p" + updateRecord: {namespace: "l0.p", name: "P", version: "2.0.0"} + } + } + } + code, detail = UpdateTask.run task + ut\assertEquals code, UpdateStatus.Installed + ut\assertEquals detail, "2.0.0" + ut\assertNotNil task.calls.installProvider + ut\assertNil task.calls.performUpdate + + -- run(): when the chosen direct source already satisfies the installed version, short-circuit to 0 + run_upToDateShortCircuits: (ut) -> + task = makeRunTask { + virtual: false, installedSatisfies: true + resolution: { + installRequired: true, stickiness: SourceChoiceStickiness.Once, maxVersion: 0 + selectedSource: {isDirect: true, updateRecord: {version: "1.0.0"}} + } + } + code = UpdateTask.run task + ut\assertEquals code, UpdateStatus.UpToDate + ut\assertNil task.calls.performUpdate -- no install performed + ut\assertNotNil task.calls.persisted -- but the source choice is still recorded + + -- run(): a terminal resolution (no install required) returns its status without dispatching + run_terminalResolutionReturnsStatus: (ut) -> + task = makeRunTask { + resolution: {installRequired: false, statusCode: UpdateStatus.UntrustedFeed, statusDetailMessage: "feed://x"} + } + code, detail = UpdateTask.run task + ut\assertEquals code, UpdateStatus.UntrustedFeed + ut\assertEquals detail, "feed://x" + ut\assertNil task.calls.persisted + ut\assertNil task.calls.performUpdate + + -- run(): the internet-connectivity guard fails (-7) before resolution is even attempted + run_noInternetGuard: (ut) -> + task = makeRunTask {online: false, resolution: {installRequired: false, statusCode: UpdateStatus.UpToDate}} + code = UpdateTask.run task + ut\assertEquals code, UpdateStatus.NoInternet + ut\assertNil task.calls.resolved -- the guard returns before resolve() + + -- __installProvider: builds a virtual provider record from the feed entry, appends the provider's + -- feed to addFeeds, and installs it through the updater (forwarding targetVersion/optional/reason) + installProvider_constructsRecordAndRequires: (ut) -> + cap = {} + fakeRef = {ref: true} + task = makeProviderTask { + depCtrl: ((args) -> {ctorArgs: args}) + addFeeds: {"feed://a"}, targetVersion: 0x10000, optional: true, reason: UpdateReason.UserRequested + requireReturn: {fakeRef}, capture: cap + } + provider = {namespace: "l0.prov", name: "Prov", url: "http://prov/x.zip"} + ref = UpdateTask.__installProvider task, provider, "feed://prov" + ut\assertEquals ref, fakeRef + ctor = cap.record.ctorArgs + ut\assertEquals ctor.moduleName, "l0.prov" + ut\assertEquals ctor.version, -1 + ut\assertTrue ctor.virtual + ut\assertEquals ctor.feed, "feed://prov" + ut\assertEquals ctor.url, "http://prov/x.zip" + ut\assertEquals cap.addFeeds[1], "feed://a" + ut\assertEquals cap.addFeeds[2], "feed://prov" -- the provider's feed is appended + ut\assertEquals cap.targetVersion, 0x10000 + ut\assertTrue cap.optional + ut\assertEquals cap.reason, UpdateReason.UserRequested + + -- performUpdate: a temp-directory creation failure aborts with -30 + performUpdate_tempDirFailure: (ut) -> + ut\stub(FileOps, "getTempDir")\returns "tmp" + ut\stub(FileOps, "mkdir")\returns nil, "denied" + code = UpdateTask.performUpdate makePerformTask!, {version: 0x10000, files: {}} + ut\assertEquals code, UpdateStatus.TempDirFailed + + -- performUpdate: a file name containing ".." is rejected as a path-traversal attempt (-33) + performUpdate_rejectsPathTraversal: (ut) -> + ut\stub(FileOps, "getTempDir")\returns "tmp" + ut\stub(FileOps, "mkdir")\returns true, "tmp" + update = {version: 0x10000, files: {{name: "../evil.moon", type: "script"}}} + code, detail = UpdateTask.performUpdate makePerformTask!, update + ut\assertEquals code, UpdateStatus.PathTraversal + ut\assertEquals detail, "../evil.moon" + + -- performUpdate: a file with a malformed sha1 hash is rejected (-35) + performUpdate_rejectsBadSha1: (ut) -> + ut\stub(FileOps, "getTempDir")\returns "tmp" + ut\stub(FileOps, "mkdir")\returns true, "tmp" + ut\stub(UpdateFeed, "getFileDeployPath")\returns "deploy/x.moon" + update = {version: 0x10000, files: {{name: "x.moon", type: "script", sha1: "abc"}}} -- not 40 hex chars + code = UpdateTask.performUpdate makePerformTask!, update + ut\assertEquals code, UpdateStatus.BadHash + + -- performUpdate: a download that ends in a Failed status is reported (-245) + performUpdate_reportsFailedDownloads: (ut) -> + ut\stub(FileOps, "getTempDir")\returns "tmp" + ut\stub(FileOps, "mkdir")\returns true, "tmp" + ut\stub(UpdateFeed, "getFileDeployPath")\returns "deploy/x.moon" + ut\stub(FileOps, "verifyHash")\returns false -- not already on disk → it gets downloaded + task = makePerformTask {downloadStatus: Downloader.Download.Status.Failed} + update = {version: 0x10000, files: {{name: "x.moon", type: "script", url: "http://x", sha1: string.rep "a", 40}}} + code = UpdateTask.performUpdate task, update + ut\assertEquals code, UpdateStatus.DownloadFailed + + -- performUpdate: a failed file move (after a successful download) is reported (-50) + performUpdate_reportsMoveFailures: (ut) -> + ut\stub(FileOps, "getTempDir")\returns "tmp" + ut\stub(FileOps, "mkdir")\returns true, "tmp" + ut\stub(UpdateFeed, "getFileDeployPath")\returns "deploy/x.moon" + ut\stub(FileOps, "verifyHash")\returns false + ut\stub(FileOps, "move")\returns nil, "move denied" + task = makePerformTask {downloadStatus: Downloader.Download.Status.Finished} + update = {version: 0x10000, files: {{name: "x.moon", type: "script", url: "http://x", sha1: string.rep "a", 40}}} + code = UpdateTask.performUpdate task, update + ut\assertEquals code, UpdateStatus.MoveFailed + + -- performUpdate happy path (module): after a successful download+move, the module is reloaded and the + -- task's record is swapped to the fresh DependencyControl version record; returns 1 and the new version + performUpdate_reloadsModuleAndRefreshesRecord: (ut) -> + ut\stub(FileOps, "getTempDir")\returns "tmp" + ut\stub(FileOps, "mkdir")\returns true, "tmp" + ut\stub(UpdateFeed, "getFileDeployPath")\returns "deploy/x.moon" + ut\stub(FileOps, "verifyHash")\returns false + ut\stub(FileOps, "move")\returns true + ut\stub(FileOps, "rmdir")\returns true + newRecord = {__class: {__name: "DependencyControl"}, version: SemanticVersion\toPacked "1.0.0"} + ut\stub(ModuleLoader, "loadModule")\returns {version: newRecord} + task = makePerformTask {downloadStatus: Downloader.Download.Status.Finished} + update = { + version: "1.0.0", getChangelog: ((rec, ver) => "") + files: {{name: "x.moon", type: "script", url: "http://x", sha1: string.rep "a", 40}} + } + code, detail = UpdateTask.performUpdate task, update + ut\assertEquals code, UpdateStatus.Installed + ut\assertEquals detail, "1.0.0" + ut\assertTrue task.updated + ut\assertIs task.record, newRecord -- record swapped to the freshly-loaded version record + + -- refreshRecord: another updater installed the module while we waited for the lock → adopt the result + refreshRecord_detectsExternalInstall: (ut) -> + loadedRef = {fresh: true} + ut\stub(ModuleLoader, "loadModule")\returns loadedRef + record = { + virtual: true, version: 0, scriptType: Common.ScriptType.Module, name: "Dep" + loadConfig: (force) => + @virtual = false + @version = SemanticVersion\toPacked "1.0.0" + } + task = stubSelf UpdateTask, {updated: false, logger: makeNullLogger!, :record} + UpdateTask.refreshRecord task + ut\assertTrue task.updated + ut\assertIs task.ref, loadedRef + + -- refreshRecord: nothing changed since the last check → the task is left untouched + refreshRecord_noChangeStaysUntouched: (ut) -> + record = { + virtual: false, version: SemanticVersion\toPacked "1.0.0" + scriptType: Common.ScriptType.Module, name: "Dep", loadConfig: (force) => nil + } + task = stubSelf UpdateTask, {updated: false, logger: makeNullLogger!, :record} + UpdateTask.refreshRecord task + ut\assertFalse task.updated + + -- __loadFeed refuses a blocked/never feed before any network fetch, surfacing a reason + loadFeed_refusesDeniedFeed: (ut) -> + feedTrust = {getFetchDecision: (url) => FeedTrust.FetchDecision.Deny} + task = stubSelf UpdateTask, { + updater: {feedTrust: feedTrust} + logger: makeNullLogger! + } + feed, err = UpdateTask.__loadFeed task, "feed://untrusted" + ut\assertNil feed + ut\assertString err + + -- getUpdaterErrorMsg: the noun install/update terms read grammatically in every template, + -- a nil isInstall renders as an update, and a nil or unmapped code falls back to the generic message + getUpdaterErrorMsg_grammarAndNilInstall: (ut) -> + msg = UpdateTask.getUpdaterErrorMsg UpdateStatus.TempDirFailed, "X", Common.ScriptType.Module, true, "C:/tmp" + ut\assertContains msg, "Couldn't complete the installation of module 'X'" + msg = UpdateTask.getUpdaterErrorMsg UpdateStatus.Unmanaged, "Y", Common.ScriptType.Module, nil + ut\assertContains msg, "Skipping update of unmanaged module 'Y'" + msg = UpdateTask.getUpdaterErrorMsg nil, "Z", Common.ScriptType.Module, true + ut\assertContains msg, "unrecognized updater status: nil" + msg = UpdateTask.getUpdaterErrorMsg UpdateStatus.SkippedOptional, "Z", Common.ScriptType.Module, true + ut\assertContains msg, "unrecognized updater status: 3" + + -- getUpdaterErrorMsg: a RequirementsUnmet message carries the nested requirement-failure detail + -- (e.g. which required module couldn't be installed), rather than dropping it + getUpdaterErrorMsg_requirementsUnmetKeepsDetail: (ut) -> + msg = UpdateTask.getUpdaterErrorMsg UpdateStatus.RequirementsUnmet, "l0.ASSFoundation", + Common.ScriptType.Module, true, "— SubInspector.Inspector: no build for your platform" + ut\assertContains msg, "requirements could not be satisfied" + ut\assertContains msg, "SubInspector.Inspector: no build for your platform" + + -- __getOfferedBuildPlatforms: collects the platforms a version-satisfying build is offered for when + -- none covers the current one (de-duped, sorted), ignoring platform-supporting, too-old, or indirect + -- candidates + getOfferedBuildPlatforms_collectsVersionMatchingRejects: (ut) -> + task = stubSelf UpdateTask, {targetVersion: SemanticVersion\toPacked "1.0.0"} + candidates = { + makeCandidate 1, "1.2.0", {platform: false, platforms: {"Windows-x64", "OSX-x64"}} -- version ok, wrong platform → collect + makeCandidate 1, "1.5.0", {platform: false, platforms: {"Windows-x64"}} -- duplicate platform → de-dup + makeCandidate 1, "1.0.0", {platform: true, platforms: {"Linux-x64"}} -- supports platform → ignore + makeCandidate 1, "0.9.0", {platform: false, platforms: {"SomethingElse"}} -- too old → ignore + makeCandidate 1, "1.0.0", {isDirect: false, platform: false, platforms: {"Provider"}} -- indirect → ignore + } + platforms = UpdateTask.__getOfferedBuildPlatforms task, candidates + ut\assertItemsEqual platforms, {"OSX-x64", "Windows-x64"} + + _order: { + "loadFeed_refusesDeniedFeed", + "getUpdaterErrorMsg_grammarAndNilInstall", "getUpdaterErrorMsg_requirementsUnmetKeepsDetail", + "getOfferedBuildPlatforms_collectsVersionMatchingRejects", + "selectCandidate_filtersByVersion", "selectCandidate_noneSatisfiesVersion", + "selectCandidate_skipsUnsupportedPlatform", "selectCandidate_skipsEmptyFiles", "selectCandidate_noneEligible", + "selectCandidate_lowerBandBeatsHigherVersion", "selectCandidate_highestVersionWithinBand", + "selectCandidate_declaredFeedTiebreak", "selectCandidate_namespaceTiebreakLogged", "selectCandidate_unambiguousNoLog", + "selectCandidate_providesVersionRangeSatisfies", "selectCandidate_providesVersionRangeRejects", + "selectCandidate_providesVersionRangeAboveTarget", "selectCandidate_providerNoRangeMatchesAny", + "selectCandidate_providerRankedByRangeMaxNotRelease", "selectCandidate_returnsTiedSet", + "isWithinContextCeiling_ranksLadder", + "shouldPrompt_withinThreshold", "shouldPrompt_aboveThreshold", "shouldPrompt_offNeverPrompts", + "shouldPrompt_noReason", "shouldPrompt_feedTrustDefaultAllowsAutoUpdate", + "promptTrustFeed_trustOnce", "promptTrustFeed_cancelReturnsNil", "promptTrustFeed_trustAlwaysPersists", + "promptTrustFeed_neverBlocks", + "promptSelectPackageSource_picksSelection", "promptSelectPackageSource_autoKeepsWinner", + "promptSelectPackageSource_abortReturnsNil", + "resolveRememberedFeedUrl_selfDeclared", "resolveRememberedFeedUrl_userFeed", "resolveRememberedFeedUrl_other", + "resolveRememberedFeedUrl_provider", "resolveRememberedFeedUrl_providerMissing", + "matchRememberedCandidate_direct", "matchRememberedCandidate_provider", + "matchRememberedCandidate_ineligibleVersion", "matchRememberedCandidate_noUrlMatch", + "feedSourceOf_provider", "feedSourceOf_selfDeclared", "feedSourceOf_userFeed", "feedSourceOf_other", + "persistSource_writesDirect", "persistSource_recordsProvider", "persistSource_storesFeedUrlForOther", + "persistSource_skipsUnchanged", + "resolve_cascadeShortCircuitsOnDeclaredDirect", "resolve_fallsThroughToExtraFeed", + "resolve_fallsThroughToTrustedDirect", + "resolve_untrustedRequiredFailsWithoutPrompt", "resolve_untrustedApprovedProceeds", + "resolve_untrustedOptionalSkips", "resolve_noCandidateRequiredFails", "resolve_noCandidateOptionalSkips", + "resolve_noPlatformBuildReportsPlatforms", + "resolve_pinnedReuseProceeds", "resolve_pinnedMissingRequiredAborts", "resolve_pinnedMissingOptionalSkips", + "resolve_retainReuseProceeds", "resolve_retainMissingNonInteractiveDowngrades", + "resolve_retainMissingInteractivePicks", "resolve_choiceAbortRequiredFails", + "resolve_autoNeverPrompts", "resolve_offerAllSourcesPromptsOnMultiple", + "resolve_blockedFeedSkipped", "resolve_userFeedUsedExclusively", + "run_dispatchesDirectInstall", "run_dispatchesProviderInstall", "run_upToDateShortCircuits", + "run_terminalResolutionReturnsStatus", "run_noInternetGuard", + "installProvider_constructsRecordAndRequires", + "performUpdate_tempDirFailure", "performUpdate_rejectsPathTraversal", "performUpdate_rejectsBadSha1", + "performUpdate_reportsFailedDownloads", "performUpdate_reportsMoveFailures", + "performUpdate_reloadsModuleAndRefreshesRecord", + "refreshRecord_detectsExternalInstall", "refreshRecord_noChangeStaysUntouched" + } + } diff --git a/modules/l0/DependencyControl/test/Updater.moon b/modules/l0/DependencyControl/test/Updater.moon new file mode 100644 index 0000000..2487013 --- /dev/null +++ b/modules/l0/DependencyControl/test/Updater.moon @@ -0,0 +1,274 @@ +-- Updater tests. +-- Called from test.moon as: (controls\requireTest "Updater")! +() -> + Updater = require "l0.DependencyControl.Updater" + Common = require "l0.DependencyControl.Common" + ModuleLoader = require "l0.DependencyControl.ModuleLoader" + SemanticVersion = require "l0.DependencyControl.SemanticVersion" + Lock = require "l0.DependencyControl.Lock" + UpdateTask = require "l0.DependencyControl.UpdateTask" + DependencyControl = require "l0.DependencyControl" + {:stubSelf, :makeNullLogger} = require "l0.DependencyControl.test.helpers.stub-helpers" + UpdateStatus = Updater.UpdateStatus + UpdateReason = UpdateTask.UpdateReason + ContextCeiling = UpdateTask.ContextCeiling + + -- A stub updater self for require(): @addTask returns the supplied task (whose run() yields the + -- scripted code/detail), so require's dispatch is exercised without constructing a real UpdateTask. + makeRequireUpdater = (task) -> + stubSelf Updater, { + logger: makeNullLogger! + addTask: ((record, tv, af, opt, ch, rsn) => task) + } + + -- A stub updater self for scheduleUpdate(): its config gates the run and @addTask returns opts.task. + makeScheduleUpdater = (opts = {}) -> + stubSelf Updater, { + config: {c: {updates: {mode: opts.mode, checkInterval: opts.updateInterval or 0}}} + logger: makeNullLogger! + addTask: ((record) => opts.task) + } + + { + _description: "Tests for Updater: require/scheduleUpdate dispatch, addTask, and lock handling." + + -- require: dispatches on the task's run() result. + + -- run reports up-to-date for a module that wasn't (re)installed → load the existing module + require_upToDateLoadsModule: (ut) -> + loadedRef = {loaded: true} + loadStub = ut\stub(ModuleLoader, "loadModule")\returns loadedRef + task = {updated: false, ref: {wrong: true}, record: {namespace: "l0.dep", name: "Dep"}, run: ((wait) => UpdateStatus.UpToDate)} + record = {scriptType: Common.ScriptType.Module, name: "Dep", namespace: "l0.dep", virtual: false} + ut\assertEquals (Updater.require makeRequireUpdater(task), record, 0), loadedRef + -- loadModule must receive the record as its module spec, not the namespace string (a string would crash) + loadStub\assertCalledWith task.record, task.record + + -- a successful (re)install returns the task's freshly-loaded ref along with the status code + require_successReturnsRef: (ut) -> + task = {updated: true, ref: {the: "ref"}, record: {namespace: "l0.dep", name: "Dep"}, run: ((wait) => UpdateStatus.Installed)} + record = {scriptType: Common.ScriptType.Module, name: "Dep", namespace: "l0.dep", virtual: false} + ref, code = Updater.require makeRequireUpdater(task), record, 0 + ut\assertEquals ref, task.ref + ut\assertEquals code, UpdateStatus.Installed + + -- a skipped optional dependency yields no ref but still carries its status code + require_skippedOptionalReturnsCode: (ut) -> + task = {updated: false, record: {namespace: "l0.dep", name: "Dep"}, run: ((wait) => UpdateStatus.SkippedOptional)} + record = {scriptType: Common.ScriptType.Module, name: "Dep", namespace: "l0.dep", virtual: true} + ref, code = Updater.require makeRequireUpdater(task), record, 0 + ut\assertNil ref + ut\assertEquals code, UpdateStatus.SkippedOptional + + -- run reports up-to-date but the module then fails to load: the paradox surfaces as a code and detail + require_upToDateLoadFailureReturnsCode: (ut) -> + ut\stub(ModuleLoader, "loadModule")\calls -> nil + task = {updated: false, record: {namespace: "l0.dep", name: "Dep"}, run: ((wait) => UpdateStatus.UpToDate)} + record = {scriptType: Common.ScriptType.Module, name: "Dep", namespace: "l0.dep", virtual: false} + ref, code, detail = Updater.require makeRequireUpdater(task), record, 0 + ut\assertNil ref + ut\assertEquals code, UpdateStatus.UpToDate + ut\assertString detail + + -- an update error (negative code) is passed through to the caller + require_errorPropagates: (ut) -> + task = {updated: false, ref: {}, record: {namespace: "l0.dep", name: "Dep"}, run: ((wait) => return UpdateStatus.NoSuitablePackage, "boom")} + record = {scriptType: Common.ScriptType.Module, name: "Dep", namespace: "l0.dep", virtual: false} + ref, code, detail = Updater.require makeRequireUpdater(task), record, 0 + ut\assertNil ref + ut\assertEquals code, UpdateStatus.NoSuitablePackage + ut\assertEquals detail, "boom" + + -- scheduleUpdate: guards, then runs a due update. + + scheduleUpdate_disabledRejected: (ut) -> + updater = makeScheduleUpdater {mode: ContextCeiling.Off} + ut\assertEquals (Updater.scheduleUpdate updater, {name: "X", namespace: "l0.x"}), UpdateStatus.UpdaterDisabled + + -- background checks sit on the auto-update rung: any lower mode refuses them + scheduleUpdate_belowAutoUpdateModeRejected: (ut) -> + updater = makeScheduleUpdater {mode: ContextCeiling.DependencyResolution} + ut\assertEquals (Updater.scheduleUpdate updater, {name: "X", namespace: "l0.x"}), UpdateStatus.UpdaterDisabled + + scheduleUpdate_virtualRejected: (ut) -> + updater = makeScheduleUpdater {mode: ContextCeiling.AutoUpdate} + ut\assertEquals (Updater.scheduleUpdate updater, {virtual: true, name: "X", namespace: "l0.x"}), UpdateStatus.Unmanaged + + scheduleUpdate_withinIntervalSkips: (ut) -> + updater = makeScheduleUpdater {mode: ContextCeiling.AutoUpdate, updateInterval: 100000} + record = {virtual: false, name: "X", namespace: "l0.x", config: {c: {lastUpdateCheck: os.time!}}} + ut\assertEquals (Updater.scheduleUpdate updater, record), UpdateStatus.UpToDate + + -- the entry point is in Aegisub's ?data automation dir (isUserPath false) → don't shadow it + scheduleUpdate_protectedInstallRejected: (ut) -> + updater = makeScheduleUpdater {mode: ContextCeiling.AutoUpdate, updateInterval: 0} + record = { + virtual: false, name: "X", namespace: "l0.x", scriptType: Common.ScriptType.Module + config: {c: {}, save: (=>)} + getEntryPointPath: (=> "data/path", false) + } + code, path = Updater.scheduleUpdate updater, record + ut\assertEquals code, UpdateStatus.ProtectedInstall + ut\assertEquals path, "data/path" + + scheduleUpdate_runsTaskWhenDue: (ut) -> + task = {run: (=> UpdateStatus.Installed)} + updater = makeScheduleUpdater {mode: ContextCeiling.AutoUpdate, updateInterval: 0, :task} + record = { + virtual: false, name: "X", namespace: "l0.x", scriptType: Common.ScriptType.Module + config: {c: {}, save: (=>)} + getEntryPointPath: (=> "user/path", true) + } + ut\assertEquals (Updater.scheduleUpdate updater, record), UpdateStatus.Installed + + -- acquireLock / releaseLock / renewLock: the lock state machine. + + acquireLock_returnsTrueWhenAlreadyHeld: (ut) -> + updater = stubSelf Updater, {hasLock: true, config: {c: {updates: {waitTimeout: 5}}}} + ut\assertTrue Updater.acquireLock updater, false + + acquireLock_acquiresAndSetsHasLock: (ut) -> + fakeLock = {lock: ((timeout) => Lock.LockState.Held, 0), getActiveHolder: (=>)} + updater = stubSelf Updater, { + hasLock: false, config: {c: {updates: {waitTimeout: 5}}}, logger: makeNullLogger! + tasks: {[Common.ScriptType.Module]: {}} + feedLoader: {cache: {expireAll: ->}} + lock: fakeLock -- pre-set so the lazy `@lock or= Lock{…}` in acquireLock skips construction + } + ut\assertTrue Updater.acquireLock updater, false + ut\assertTrue updater.hasLock + + acquireLock_failsWhenHeldByOther: (ut) -> + fakeLock = {lock: ((timeout) => Lock.LockState.Unavailable, 0), getActiveHolder: (=> {holderName: "OtherScript"})} + updater = stubSelf Updater, { + hasLock: false, config: {c: {updates: {waitTimeout: 5}}}, logger: makeNullLogger! + lock: fakeLock + } + ok, owner = Updater.acquireLock updater, false + ut\assertFalse ok + ut\assertEquals owner, "OtherScript" + + releaseLock_releasesWhenHeld: (ut) -> + released = {} + updater = stubSelf Updater, {hasLock: true, lock: {release: (=> released.called = true)}} + ut\assertTrue Updater.releaseLock updater + ut\assertFalse updater.hasLock + ut\assertTrue released.called + + releaseLock_noopWhenNotHeld: (ut) -> + updater = stubSelf Updater, {hasLock: false} + ut\assertFalse Updater.releaseLock updater + + renewLock_renewsWhenHeld: (ut) -> + renewed = {} + updater = stubSelf Updater, {hasLock: true, lock: {renew: (=> renewed.called = true)}} + Updater.renewLock updater + ut\assertTrue renewed.called + + -- addTask: version parsing and task caching. + + addTask_versionParseErrorReturns: (ut) -> + updater = stubSelf Updater, {tasks: {}} + record = {__class: DependencyControl, scriptType: Common.ScriptType.Module, namespace: "l0.x"} + task, code, err = Updater.addTask updater, record, "not-a-version" + ut\assertNil task + ut\assertEquals code, UpdateStatus.InvalidVersion + ut\assertNotNil err + + -- a record with a queued task updates that task in place rather than creating a new one + addTask_updatesExistingTask: (ut) -> + existing = {targetVersion: 0} + record = {__class: DependencyControl, scriptType: Common.ScriptType.Module, namespace: "l0.x"} + updater = stubSelf Updater, { + tasks: {[Common.ScriptType.Module]: {[record.namespace]: existing}} + } + task = Updater.addTask updater, record, "2.0.0", {"feed://a"}, true + ut\assertIs task, existing + ut\assertEquals existing.targetVersion, SemanticVersion\toPacked "2.0.0" + ut\assertTrue existing.optional + + -- a record with no queued task gets a fresh UpdateTask, which is cached under its scriptType/namespace + addTask_createsNewTask: (ut) -> + record = {__class: DependencyControl, scriptType: Common.ScriptType.Module, namespace: "l0.new", validateNamespace: => true} + updater = stubSelf Updater, { + tasks: {[Common.ScriptType.Module]: {}} + logger: makeNullLogger! + config: {c: {updates: {mode: ContextCeiling.AutoUpdate}, paths: {cache: "?user/cache"}}} + } + task = Updater.addTask updater, record, "1.0.0" + ut\assertNotNil task + ut\assertIs task.__class, UpdateTask + ut\assertIs updater.tasks[Common.ScriptType.Module][record.namespace], task + + -- addTask rejects creation for a disabled updater or an invalid namespace: a constructor's return value is + -- discarded, so the guards live in addTask rather than UpdateTask.new + addTask_disabledUpdaterRejects: (ut) -> + record = {__class: DependencyControl, scriptType: Common.ScriptType.Module, namespace: "l0.new", validateNamespace: => true} + updater = stubSelf Updater, { + tasks: {[Common.ScriptType.Module]: {}}, config: {c: {updates: {mode: ContextCeiling.Off}}} + } + task, code = Updater.addTask updater, record, "1.0.0" + ut\assertNil task + ut\assertEquals code, UpdateStatus.UpdaterDisabled + + -- the update mode gates addTask by the task's reason: a user-requested action still passes a mode + -- that blocks background checks + addTask_modeGatesByReason: (ut) -> + record = {__class: DependencyControl, scriptType: Common.ScriptType.Module, namespace: "l0.new", validateNamespace: => true} + makeUpdater = -> stubSelf Updater, { + tasks: {[Common.ScriptType.Module]: {}} + logger: makeNullLogger! + config: {c: {updates: {mode: ContextCeiling.UserRequested}, paths: {cache: "?user/cache"}}} + } + task, code = Updater.addTask makeUpdater!, record, "1.0.0" -- default reason: AutoUpdate + ut\assertNil task + ut\assertEquals code, UpdateStatus.UpdaterDisabled + task = Updater.addTask makeUpdater!, record, "1.0.0", nil, nil, nil, UpdateReason.UserRequested + ut\assertNotNil task + + addTask_invalidNamespaceRejects: (ut) -> + record = {__class: DependencyControl, scriptType: Common.ScriptType.Module, namespace: "bad ns", validateNamespace: => false} + updater = stubSelf Updater, { + tasks: {[Common.ScriptType.Module]: {}}, config: {c: {updates: {mode: ContextCeiling.AutoUpdate}}} + } + task, code = Updater.addTask updater, record, "1.0.0" + ut\assertNil task + ut\assertEquals code, UpdateStatus.InvalidNamespace + + -- the feed-trust model is a process-wide singleton: every Updater shares the one instance + feedTrust_isSharedSingleton: (ut) -> + cfg = {c: {feeds: {extraFeeds: {}, trustedFeeds: {}, blockedFeeds: {}}}} + a, b = Updater("hostA", cfg), Updater("hostB", cfg) + ut\assertIs a.feedTrust, b.feedTrust + + -- an unset `updates.mode` defaults to auto-update: every context is enabled + isEnabledFor_defaultsToAllContexts: (ut) -> + make = (mode) -> stubSelf Updater, {config: {c: {updates: {:mode}}}} + for reason in *{UpdateReason.UserRequested, UpdateReason.DependencyResolution, UpdateReason.AutoUpdate} + ut\assertTrue Updater.__isEnabledFor make!, reason + + -- each mode enables exactly the contexts at or below its rung; off enables none + isEnabledFor_modeGatesByContext: (ut) -> + make = (mode) -> stubSelf Updater, {config: {c: {updates: {:mode}}}} + ut\assertFalse Updater.__isEnabledFor make(ContextCeiling.Off), UpdateReason.UserRequested + ut\assertTrue Updater.__isEnabledFor make(ContextCeiling.UserRequested), UpdateReason.UserRequested + ut\assertFalse Updater.__isEnabledFor make(ContextCeiling.UserRequested), UpdateReason.DependencyResolution + ut\assertTrue Updater.__isEnabledFor make(ContextCeiling.DependencyResolution), UpdateReason.DependencyResolution + ut\assertFalse Updater.__isEnabledFor make(ContextCeiling.DependencyResolution), UpdateReason.AutoUpdate + ut\assertTrue Updater.__isEnabledFor make(ContextCeiling.AutoUpdate), UpdateReason.AutoUpdate + + _order: { + "require_upToDateLoadsModule", "require_successReturnsRef", "require_errorPropagates" + "require_skippedOptionalReturnsCode", "require_upToDateLoadFailureReturnsCode" + "scheduleUpdate_disabledRejected", "scheduleUpdate_belowAutoUpdateModeRejected" + "scheduleUpdate_virtualRejected" + "scheduleUpdate_withinIntervalSkips", "scheduleUpdate_protectedInstallRejected" + "scheduleUpdate_runsTaskWhenDue" + "acquireLock_returnsTrueWhenAlreadyHeld", "acquireLock_acquiresAndSetsHasLock" + "acquireLock_failsWhenHeldByOther", "releaseLock_releasesWhenHeld", "releaseLock_noopWhenNotHeld" + "renewLock_renewsWhenHeld" + "addTask_versionParseErrorReturns", "addTask_updatesExistingTask", "addTask_createsNewTask" + "addTask_disabledUpdaterRejects", "addTask_modeGatesByReason", "addTask_invalidNamespaceRejects" + "feedTrust_isSharedSingleton", "isEnabledFor_defaultsToAllContexts", "isEnabledFor_modeGatesByContext" + } + } diff --git a/modules/l0/DependencyControl/test/ZipArchiver.moon b/modules/l0/DependencyControl/test/ZipArchiver.moon new file mode 100644 index 0000000..e6d880a --- /dev/null +++ b/modules/l0/DependencyControl/test/ZipArchiver.moon @@ -0,0 +1,231 @@ +-- ZipArchiver tests: entry collection and the per-platform archive writers. +-- The platform-specific writers (__writeWindows/__writeUnix) are exercised by calling +-- them directly with all filesystem and shell-out calls stubbed, so both paths run +-- regardless of the host OS. A real archive round-trip lives in test/integration/ZipArchiver. +-- Called from Tests.moon as: (require "...test.ZipArchiver") basePath +(basePath) -> + lfs = require "lfs" + FileOps = require "l0.DependencyControl.FileOps" + ZipArchiver = require "l0.DependencyControl.ZipArchiver" + + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + JSON_MODULE_NAME = "l0.dkjson" + pathSep = FileOps.pathSep + + -- Fake io handle supporting the `h\write(data)\close!` chain the writers use. + makeHandle = -> {write: ((self, data) -> self), close: ((self) -> nil)} + + -- Stubs lfs.attributes + lfs.dir over a fake tree so addDirectory can be driven without + -- touching disk. `dirs` is a set of directory paths; `children` maps a dir path to its + -- entry names (callers include "."/".." to prove they're skipped). + stubTree = (ut, dirs, children) -> + (ut\stub lfs, "attributes")\calls (path, key) -> dirs[path] and "directory" or "file" + (ut\stub lfs, "dir")\calls (path) -> + names, i = children[path] or {}, 0 + -> + i += 1 + names[i] + + { + _description: "Tests for ZipArchiver entry collection and the per-platform archive writers." + + -- new + + new_initializes: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + ut\assertEquals arch.outputPath, "#{basePath}/out.zip" + ut\assertTable arch.entries + ut\assertEquals #arch.entries, 0 + ut\assertNotNil arch.logger + + -- addFile + + addFile_appendsAndChains: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + ret = arch\addFile "/src/a.txt", "a.txt" + ut\assertEquals #arch.entries, 1 + ut\assertEquals arch.entries[1].source, "/src/a.txt" + ut\assertEquals arch.entries[1].name, "a.txt" + ut\assertIs ret, arch -- returns self for chaining + + -- addDirectory + + addDirectory_missingDir: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> false + arch = ZipArchiver "#{basePath}/out.zip" + ret = arch\addDirectory "#{basePath}/nope" + ut\assertEquals #arch.entries, 0 + ut\assertIs ret, arch + + addDirectory_flat: (ut) -> + root = "#{basePath}#{pathSep}src" + stubTree ut, {[root]: true}, {[root]: {".", "..", "a.txt", "b.txt"}} + arch = ZipArchiver "#{basePath}/out.zip" + arch\addDirectory root + ut\assertEquals #arch.entries, 2 + ut\assertEquals arch.entries[1].name, "a.txt" + ut\assertEquals arch.entries[2].name, "b.txt" + + addDirectory_nestedUsesForwardSlash: (ut) -> + root = "#{basePath}#{pathSep}src" + sub = "#{root}#{pathSep}sub" + stubTree ut, {[root]: true, [sub]: true}, + {[root]: {"a.txt", "sub"}, [sub]: {"c.txt"}} + arch = ZipArchiver "#{basePath}/out.zip" + arch\addDirectory root + ut\assertEquals #arch.entries, 2 + byName = {entry.name, entry for entry in *arch.entries} + ut\assertNotNil byName["a.txt"] + ut\assertNotNil byName["sub/c.txt"] -- always forward-slash, even on Windows + + addDirectory_prefixNormalizesTrailingSlash: (ut) -> + root = "#{basePath}#{pathSep}src" + stubTree ut, {[root]: true}, {[root]: {"a.txt"}} + arch = ZipArchiver "#{basePath}/out.zip" + arch\addDirectory root, "pre/" -- trailing slash must not double up + ut\assertEquals arch.entries[1].name, "pre/a.txt" + + -- write: dispatch + guards + + write_noEntries: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + result, err = arch\write! + ut\assertNil result + ut\assertString err + + write_removesTargetThenDispatches: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + removeStub = (ut\stub FILEOPS_MODULE_NAME, "remove")\returns true + -- stub both writers; only the host platform's runs, but this keeps the test OS-agnostic + (ut\stub arch, "__writeWindows")\returns true + (ut\stub arch, "__writeUnix")\returns true + result = arch\write! + ut\assertTrue result + removeStub\assertCalledOnceWith "#{basePath}/out.zip" -- Create mode needs the target absent + + -- __writeWindows: manifest + helper script + PowerShell shell-out (all stubbed) + + writeWindows_success: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + (ut\stub aegisub, "decode_path")\returns basePath + (ut\stub io, "open")\returns makeHandle! + (ut\stub JSON_MODULE_NAME, "encode")\returns "[]" + (ut\stub os, "execute")\returns 0 -- exit code 0 (Lua 5.1 numeric return) + (ut\stub os, "remove")\returns true + ut\assertTrue arch\__writeWindows! + + writeWindows_manifestWriteFailure: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + (ut\stub aegisub, "decode_path")\returns basePath + (ut\stub io, "open")\returns nil, "disk full" + result, err = arch\__writeWindows! + ut\assertNil result + ut\assertContains err, "disk full" + + writeWindows_scriptWriteFailureCleansUp: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + (ut\stub aegisub, "decode_path")\returns basePath + idx = 0 + (ut\stub io, "open")\calls (path, mode) -> + idx += 1 + return makeHandle! if idx == 1 -- manifest write succeeds + nil, "boom" -- helper script write fails + (ut\stub JSON_MODULE_NAME, "encode")\returns "[]" + removeStub = (ut\stub os, "remove")\returns true + result, err = arch\__writeWindows! + ut\assertNil result + ut\assertContains err, "boom" + removeStub\assertCalledTimes 2 -- cleanup removes both temp files + + writeWindows_toolFailure: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + (ut\stub aegisub, "decode_path")\returns basePath + (ut\stub io, "open")\returns makeHandle! + (ut\stub JSON_MODULE_NAME, "encode")\returns "[]" + (ut\stub os, "execute")\returns 1 -- non-zero exit + (ut\stub os, "remove")\returns true + result, err = arch\__writeWindows! + ut\assertNil result + ut\assertContains err, "PowerShell" + + -- __writeUnix: stage into a temp tree, then run the `zip` CLI (all stubbed) + + writeUnix_success: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + (ut\stub aegisub, "decode_path")\returns basePath + (ut\stub FILEOPS_MODULE_NAME, "mkdir")\returns true + (ut\stub FILEOPS_MODULE_NAME, "copy")\returns true + (ut\stub FILEOPS_MODULE_NAME, "remove")\returns true + (ut\stub lfs, "currentdir")\returns "#{basePath}/prev" + (ut\stub lfs, "chdir")\returns true + (ut\stub lfs, "dir")\calls (path) -> + names, i = {".", "..", "a.txt"}, 0 + -> + i += 1 + names[i] + (ut\stub os, "execute")\returns true -- boolean success (Lua 5.2+/LUA52COMPAT return) + ut\assertTrue arch\__writeUnix! + + writeUnix_stageFailureCleansUp: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + (ut\stub aegisub, "decode_path")\returns basePath + (ut\stub FILEOPS_MODULE_NAME, "mkdir")\returns true + (ut\stub FILEOPS_MODULE_NAME, "copy")\returns nil, "no space" + removeStub = (ut\stub FILEOPS_MODULE_NAME, "remove")\returns true + result, err = arch\__writeUnix! + ut\assertNil result + ut\assertContains err, "/src/a.txt" -- names the file that couldn't be staged + removeStub\assertCalledOnce! -- staging dir torn down + + writeUnix_enterStageFailureCleansUp: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + (ut\stub aegisub, "decode_path")\returns basePath + (ut\stub FILEOPS_MODULE_NAME, "mkdir")\returns true + (ut\stub FILEOPS_MODULE_NAME, "copy")\returns true + removeStub = (ut\stub FILEOPS_MODULE_NAME, "remove")\returns true + (ut\stub lfs, "currentdir")\returns "#{basePath}/prev" + (ut\stub lfs, "chdir")\returns false -- can't cd into the staging dir + result, err = arch\__writeUnix! + ut\assertNil result + ut\assertContains err, "staging directory" + removeStub\assertCalledOnce! + + writeUnix_toolFailure: (ut) -> + arch = ZipArchiver "#{basePath}/out.zip" + arch\addFile "/src/a.txt", "a.txt" + (ut\stub aegisub, "decode_path")\returns basePath + (ut\stub FILEOPS_MODULE_NAME, "mkdir")\returns true + (ut\stub FILEOPS_MODULE_NAME, "copy")\returns true + (ut\stub FILEOPS_MODULE_NAME, "remove")\returns true + (ut\stub lfs, "currentdir")\returns "#{basePath}/prev" + (ut\stub lfs, "chdir")\returns true + (ut\stub lfs, "dir")\calls (path) -> + names, i = {".", "..", "a.txt"}, 0 + -> + i += 1 + names[i] + (ut\stub os, "execute")\returns 1 -- zip exits non-zero + result, err = arch\__writeUnix! + ut\assertNil result + ut\assertContains err, "zip" + + _order: { + "new_initializes", + "addFile_appendsAndChains", + "addDirectory_missingDir", "addDirectory_flat", + "addDirectory_nestedUsesForwardSlash", "addDirectory_prefixNormalizesTrailingSlash", + "write_noEntries", "write_removesTargetThenDispatches", + "writeWindows_success", "writeWindows_manifestWriteFailure", + "writeWindows_scriptWriteFailureCleansUp", "writeWindows_toolFailure", + "writeUnix_success", "writeUnix_stageFailureCleansUp", + "writeUnix_enterStageFailureCleansUp", "writeUnix_toolFailure" + } + } diff --git a/modules/l0/DependencyControl/test/config-schema.moon b/modules/l0/DependencyControl/test/config-schema.moon new file mode 100644 index 0000000..69e6bff --- /dev/null +++ b/modules/l0/DependencyControl/test/config-schema.moon @@ -0,0 +1,124 @@ +-- config-schema tests: the sectioned defaults tree carries the cross-cutting policy defaults and keeps every +-- section present; the co-located migration lifts the v0.6.3 flat keys into their sections, drops obsolete +-- keys, is idempotent, and preserves unknown keys. +() -> + schema = require "l0.DependencyControl.config-schema" + Common = require "l0.DependencyControl.Common" + + {:migrate, :keyMap, :droppedKeys} = schema.migration + + -- the config keys shipped in v0.6.3 (the last release), each of which the migration must handle + v063Keys = { + "updaterEnabled", "updateInterval", "traceLevel", "extraFeeds", "tryAllFeeds", "dumpFeeds", "configDir" + "logMaxFiles", "logMaxAge", "logMaxSize", "updateWaitTimeout", "updateOrphanTimeout", "logDir", "writeLogs" + } + + { + _description: "config-schema: cross-cutting sectioned defaults, plus the flat->sectioned migration." + + -- every domain section is present alongside the config schema id + hasAllSections: (ut) -> + ut\assertEquals type(schema.CONFIG_SCHEMA_ID_CURRENT), "string" + ut\assertNotNil schema.sections[name] for name in *{"updates", "feeds", "logging", "paths"} + + -- the logging policy literals (which deliberately differ from Logger's own defaults) and the new cache base + hasPolicyLiterals: (ut) -> + ut\assertEquals schema.sections.logging.defaultLevel, 3 + ut\assertTrue schema.sections.logging.toFile + ut\assertEquals schema.sections.paths.cache, "?user/cache" + + -- a flat v0.6.3 config (no root $schema) has every `config` hive key lifted into its section and renamed. + -- Each mapped key is asserted against its explicitly-expected target, so a mis-pointed migration is caught. + migratesFlatKeys: (ut) -> + c = {config: { + updaterEnabled: false, updateInterval: 999, updateWaitTimeout: 45, updateOrphanTimeout: 30 + traceLevel: 5, writeLogs: false, logMaxFiles: 12, logMaxAge: 34, logMaxSize: 56 + extraFeeds: {"x"}, configDir: "?user/config", logDir: "?user/log" + }} + ut\assertTrue migrate c, nil, schema.CONFIG_SCHEMA_ID_CURRENT + cfg = c.config + ut\assertEquals cfg.updates.mode, "off" -- updaterEnabled: false maps onto the mode domain + ut\assertEquals cfg.updates.checkInterval, 999 + ut\assertEquals cfg.updates.waitTimeout, 45 + ut\assertEquals cfg.updates.orphanTimeout, 30 + ut\assertEquals cfg.logging.defaultLevel, 5 + ut\assertEquals cfg.logging.toFile, false + ut\assertEquals cfg.logging.maxFiles, 12 + ut\assertEquals cfg.logging.maxAge, 34 + ut\assertEquals cfg.logging.maxSize, 56 + ut\assertEquals cfg.feeds.extraFeeds, {"x"} + ut\assertEquals cfg.paths.config, "?user/config" + ut\assertEquals cfg.paths.log, "?user/log" + -- the old flat keys are gone + ut\assertNil cfg.updaterEnabled + ut\assertNil cfg.traceLevel + ut\assertNil cfg.extraFeeds + + -- obsolete v0.6.3 settings are dropped, not carried into a section + dropsObsoleteKeys: (ut) -> + c = {config: {tryAllFeeds: false, dumpFeeds: true}} + migrate c, nil, schema.CONFIG_SCHEMA_ID_CURRENT + ut\assertNil c.config.tryAllFeeds + ut\assertNil c.config.dumpFeeds + + -- an obsolete pre-$schema `formatVersion` marker in the config hive is dropped on migration + dropsObsoleteFormatVersion: (ut) -> + c = {config: {formatVersion: 1, updaterEnabled: true}} + migrate c, nil, schema.CONFIG_SCHEMA_ID_CURRENT + ut\assertNil c.config.formatVersion + ut\assertEquals c.config.updates.mode, "auto-update" -- updaterEnabled: true maps onto the mode domain + + -- a config that already carries a $schema is sectioned; migrate leaves it untouched + skipsWhenSchemaPresent: (ut) -> + c = {["$schema"]: schema.CONFIG_SCHEMA_ID_CURRENT, config: {updates: {mode: "off"}}} + ut\assertFalse migrate c, schema.CONFIG_SCHEMA_ID_CURRENT, schema.CONFIG_SCHEMA_ID_CURRENT + ut\assertEquals c.config.updates.mode, "off" -- unchanged + ut\assertNil c.config.updates.checkInterval -- not re-populated with defaults + + -- keys the migration doesn't know about — arbitrary user keys and unreleased post-v0.6.3 keys alike — + -- are left in place (only dev configs have the latter, and they need no migration) + preservesUnknownKeys: (ut) -> + c = {config: {updaterEnabled: true, someUserKey: 42, trustedFeeds: {"t"}}} + migrate c, nil, schema.CONFIG_SCHEMA_ID_CURRENT + cfg = c.config + ut\assertEquals cfg.someUserKey, 42 + ut\assertEquals cfg.trustedFeeds, {"t"} -- a v0.7.0-dev key: not lifted into a section + ut\assertNil cfg.feeds + ut\assertEquals cfg.updates.mode, "auto-update" + + -- a pre-$schema config rewrites each record's boolean `unmanaged` flag into its recordType and its + -- packed-integer version into a semver string. the flag is then dropped, and a record lacking it loads as managed + migratesLegacyRecordFields: (ut) -> + c = { + macros: { + ["l0.old"]: {unmanaged: true, version: 66051, author: "x"} -- 66051 == 1.2.3 + ["l0.managed"]: {version: 2} -- 2 == 0.0.2 + } + modules: { + ["l0.mod"]: {unmanaged: true} + } + } + migrate c, nil, schema.CONFIG_SCHEMA_ID_CURRENT + ut\assertEquals c.macros["l0.old"].recordType, Common.RecordType.Unmanaged + ut\assertNil c.macros["l0.old"].unmanaged -- flag dropped + ut\assertEquals c.macros["l0.old"].version, "1.2.3" -- packed int -> semver string + ut\assertEquals c.macros["l0.old"].author, "x" -- unrelated fields untouched + ut\assertEquals c.macros["l0.managed"].version, "0.0.2" + ut\assertNil c.macros["l0.managed"].recordType -- no flag -> stays managed + ut\assertEquals c.modules["l0.mod"].recordType, Common.RecordType.Unmanaged + ut\assertNil c.modules["l0.mod"].unmanaged + + -- the migration handles exactly the v0.6.3 keys: each is either lifted or dropped, and nothing else is + handlesExactlyV063Keys: (ut) -> + handled = {k, true for k in *droppedKeys} + handled[k] = true for k in pairs keyMap + ut\assertTrue handled[k], "v0.6.3 key '#{k}' is neither migrated nor dropped" for k in *v063Keys + v063Set = {k, true for k in *v063Keys} + ut\assertTrue v063Set[k], "migration handles '#{k}', which was not a v0.6.3 key" for k in pairs handled + + _order: { + "hasAllSections", "hasPolicyLiterals" + "migratesFlatKeys", "dropsObsoleteKeys", "dropsObsoleteFormatVersion", "skipsWhenSchemaPresent" + "migratesLegacyRecordFields", "preservesUnknownKeys", "handlesExactlyV063Keys" + } + } diff --git a/modules/l0/DependencyControl/test/ffi-posix.moon b/modules/l0/DependencyControl/test/ffi-posix.moon new file mode 100644 index 0000000..894c3c3 --- /dev/null +++ b/modules/l0/DependencyControl/test/ffi-posix.moon @@ -0,0 +1,56 @@ +-- 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 + } 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..209c1b5 --- /dev/null +++ b/modules/l0/DependencyControl/test/helpers/mock-http-server.moon @@ -0,0 +1,134 @@ +-- 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/<name> full-speed response (Content-Length) +-- GET /slow/<name>?delay=<ms>&chunk=<n> chunked response, <n> bytes every <ms> ms +-- GET /status/<code> respond with the given HTTP status +-- GET /redirect-to/<path> 302 with a relative Location of /<path> +-- GET /__quit stop the server (the clean shutdown route) +-- +-- Flags: --port <n> (loopback port to listen on), --dir <d>, --max-lifetime <s> (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 target = path\match "^/redirect%-to/(.+)$" + res\statusCode 302 + res\addHeader "Location", "/#{target}" -- relative, so it also exercises redirect resolution + res\write "redirecting to /#{target}" + 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/DependencyControl/test/helpers/stub-helpers.moon b/modules/l0/DependencyControl/test/helpers/stub-helpers.moon new file mode 100644 index 0000000..397bdf0 --- /dev/null +++ b/modules/l0/DependencyControl/test/helpers/stub-helpers.moon @@ -0,0 +1,23 @@ +FeedTrust = require "l0.DependencyControl.FeedTrust" + +---Builds a stub `self` for driving a class's methods in isolation: installs `Cls.__base` as the metatable +---index so method lookups (and internal `@`/`@@` calls) resolve, while `fields` supplies the instance state +---the code under test reads. The caller sets `__class` in `fields` when the code reads it. +---@param cls table The class whose method table (`__base`) the stub inherits. +---@param fields? table The stub's own fields (default empty). +---@return table stub The `fields` table with `cls.__base` installed as its metatable index. +stubSelf = (cls, fields = {}) -> setmetatable fields, __index: cls.__base + +---Builds a logger stub whose every method is a no-op, with a live `indent` field the code under test can +---adjust. Its `assert` returns the condition and never raises, so assert-guarded code under test proceeds. +---@return table logger A logger stub: `indent` is 0, `assert` returns its first argument, and any other method call does nothing and returns nil. +makeNullLogger = -> setmetatable {indent: 0, assert: ((cond) => cond)}, __index: -> -> + +---Builds a stub FeedTrust `self` seeded with the given trust state and a null logger, for driving FeedTrust +---methods (and code that consults trust) in isolation. +---@param opts? { config?: table, official?: { trusted: table, blocked: table }, feedLoader?: table } The config view, the merged official trusted/blocked sets (stored as the private `__official`), and a feed-loader stub; each optional. +---@return table feedTrust A FeedTrust stub-self carrying the seeded fields. +makeSeededFeedTrust = (opts = {}) -> + stubSelf FeedTrust, {config: opts.config, __official: opts.official, feedLoader: opts.feedLoader, logger: makeNullLogger!} + +{:stubSelf, :makeNullLogger, :makeSeededFeedTrust} diff --git a/modules/l0/DependencyControl/test/integration/Downloader.moon b/modules/l0/DependencyControl/test/integration/Downloader.moon new file mode 100644 index 0000000..3b77edc --- /dev/null +++ b/modules/l0/DependencyControl/test/integration/Downloader.moon @@ -0,0 +1,95 @@ +-- Real-HTTP Downloader integration tests 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. +-- Called from Tests.moon as: (require "...test.integration.Downloader") basePath +(basePath) -> + Downloader = require "l0.DependencyControl.Downloader" + FileOps = require "l0.DependencyControl.FileOps" + + { + _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 + + -- a 302 (with a relative Location) is followed to the target and the file arrives intact. Exercises + -- curl's own redirect following on Unix and the WinINet backend's hand-rolled redirect loop on Windows. + followsRedirect: (ut, ctx) -> + f = ctx.fixtures[1] + dm = Downloader! + dl = dm\addDownload "#{ctx.server.baseUrl}/redirect-to/fast/#{f.name}", "#{ctx.downloadDir}/redir_#{f.name}", f.sha1 + dm\await! + ut\assertEquals dl.status, Downloader.Download.Status.Finished + + _order: { "concurrentFast", "concurrentSlow", "queuedBeyondLimit", "httpError", "followsRedirect" } + } diff --git a/modules/l0/DependencyControl/test/integration/Logger.moon b/modules/l0/DependencyControl/test/integration/Logger.moon new file mode 100644 index 0000000..7dd1969 --- /dev/null +++ b/modules/l0/DependencyControl/test/integration/Logger.moon @@ -0,0 +1,56 @@ +-- RNG seed divergence across isolated Lua states, replicating Aegisub loading one DependencyControl copy +-- per automation script simultaneously: each spawned luajit child seeds through the real Logger +-- construction and reports the head of its random stream, which must be unique per state — this is what +-- keeps per-script log file names and temp paths from colliding. +-- Self-gating via _condition: skipped where a luajit child process can't be spawned. +-- Called from Tests.moon as: (require "...test.integration.Logger") basePath +(basePath) -> + FileOps = require "l0.DependencyControl.FileOps" + + STATE_COUNT = 50 + + -- The probe a child state runs: bootstrap the parent's module paths, load the headless shim, construct + -- a Logger (which seeds the state's rng), and print the head of the resulting random stream. + buildProbeSource = -> + table.concat { + ("package.path = %q")\format package.path + ("package.cpath = %q")\format package.cpath + 'require "moonscript"' + 'require "l0.AegisubShims.aegisub"' + 'local Logger = require "l0.DependencyControl.Logger"' + 'Logger{fileBaseName = "seedProbe"}' + 'io.write(("%04x%04x%04x"):format(math.random(0, 0xFFFF), math.random(0, 0xFFFF), math.random(0, 0xFFFF)))' + }, "\n" + + { + _description: "Logger rng seeding: simultaneously created Lua states get distinct random streams (runs where a luajit child can be spawned)." + + _condition: -> + ok, handle = pcall io.popen, 'luajit -e "io.write(_VERSION)"' + return false, "io.popen unavailable (#{tostring handle})" unless ok and handle + out = handle\read "*a" + handle\close! + return false, "no runnable luajit on PATH" unless out and #out > 0 + return true + + isolatedStates_getDistinctRandomStreams: (ut) -> + FileOps.mkdir basePath, false, true + probePath = FileOps.joinPath basePath, "logger-seed-probe.lua" + ut\assertTruthy FileOps.writeFile probePath, buildProbeSource! + + -- spawn every child before reading any, so their module loads and seedings overlap in time + handles = [io.popen "luajit \"#{probePath}\" 2>&1" for _ = 1, STATE_COUNT] + streams = for handle in *handles + out = handle\read "*a" + handle\close! + out + + unique = {} + for stream in *streams + -- a failed child prints an error dump instead of hex; the mismatch surfaces it verbatim + ut\assertEquals stream\match("^%x+$"), stream + unique[stream] = true + count = 0 + count += 1 for _ in pairs unique + ut\assertEquals count, STATE_COUNT + } diff --git a/modules/l0/DependencyControl/test/integration/ZipArchiver.moon b/modules/l0/DependencyControl/test/integration/ZipArchiver.moon new file mode 100644 index 0000000..a3f8385 --- /dev/null +++ b/modules/l0/DependencyControl/test/integration/ZipArchiver.moon @@ -0,0 +1,70 @@ +-- Real archive round-trip for ZipArchiver against each platform's stock tooling. +-- Self-gating via _condition: skipped unless the build tool (PowerShell on Windows, +-- `zip` on *nix) and a matching extractor (`Expand-Archive` / `unzip`) are available, +-- so the default offline run never depends on them. +-- Called from Tests.moon as: (require "...test.integration.ZipArchiver") basePath +(basePath) -> + ffi = require "ffi" + FileOps = require "l0.DependencyControl.FileOps" + ZipArchiver = require "l0.DependencyControl.ZipArchiver" + + isWindows = ffi.os == "Windows" + + -- Runs a shell command and reports success across Lua 5.1 (numeric) and 5.2+ (boolean) returns. + execOk = (cmd) -> + r = os.execute cmd + (type(r) == "number" and r == 0) or r == true + + extractCmd = (archivePath, destDir) -> + if isWindows + ([[powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath '%s' -DestinationPath '%s' -Force"]])\format archivePath, destDir + else + ([[unzip -q -o "%s" -d "%s"]])\format archivePath, destDir + + { + _description: "Real-tooling ZipArchiver round-trip (runs when the platform zip/unzip tools are available)." + + _condition: -> + if isWindows + return false, "PowerShell unavailable" unless execOk [[powershell -NoProfile -NonInteractive -Command "exit 0"]] + else + return false, "`zip` unavailable" unless execOk "command -v zip > /dev/null 2>&1" + return false, "`unzip` unavailable" unless execOk "command -v unzip > /dev/null 2>&1" + return true + + _setup: (ut) -> + base = "#{basePath}_ziparchiver" + srcDir = "#{base}/src" + extractDir = "#{base}/extracted" + FileOps.mkdir "#{srcDir}/sub", false, true + FileOps.mkdir extractDir, false, true + + write = (path, data) -> + f = assert io.open path, "wb" + f\write data + f\close! + + write "#{srcDir}/top.txt", "top-level file" + write "#{srcDir}/sub/c.txt", "nested file contents" + + {:base, :srcDir, :extractDir, archivePath: "#{base}/out.zip"} + + _teardown: (ut, ctx) -> + FileOps.remove ctx.base, true if ctx and ctx.base + + -- A directory added by ZipArchiver must round-trip through the real tooling with its + -- nested entry recreated under a forward-slash subpath on every platform. + roundTrip_preservesNestedEntries: (ut, ctx) -> + arch = ZipArchiver ctx.archivePath + arch\addDirectory ctx.srcDir + ok, err = arch\write! + ut\assertTrue ok, err + ut\assertTrue FileOps.exists ctx.archivePath + + ut\assertTrue execOk(extractCmd ctx.archivePath, ctx.extractDir), "extraction failed" + ut\assertTrue FileOps.exists "#{ctx.extractDir}/top.txt" + ut\assertTrue FileOps.exists "#{ctx.extractDir}/sub/c.txt" -- nested path materialized + + data = FileOps.readFile "#{ctx.extractDir}/sub/c.txt" + ut\assertEquals data, "nested file contents" + } diff --git a/modules/l0/DependencyControl/test/open-url.moon b/modules/l0/DependencyControl/test/open-url.moon new file mode 100644 index 0000000..e924532 --- /dev/null +++ b/modules/l0/DependencyControl/test/open-url.moon @@ -0,0 +1,50 @@ +-- open-url tests: the http(s)-only validation gate and the POSIX shell-safe command building. The actual +-- open() is a thin, platform-dependent I/O wrapper and isn't exercised here. +() -> + UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + openUrl = require "l0.DependencyControl.helpers.open-url" + {:isSafeUrl, :buildPosixOpenCommand} = UnitTestSuite\getTestExports openUrl + + { + _description: "open-url: URL validation gate and POSIX shell-safe command building." + + -- ordinary http(s) URLs are accepted + isSafeUrl_acceptsHttp: (ut) -> + ut\assertTrue isSafeUrl "https://raw.githubusercontent.com/x/y/DependencyControl.json" + ut\assertTrue isSafeUrl "http://example.com/feed" + + -- non-http(s) schemes are rejected (no file://, javascript:, ftp://) + isSafeUrl_rejectsOtherSchemes: (ut) -> + ut\assertFalse isSafeUrl "file:///etc/passwd" + ut\assertFalse isSafeUrl "javascript:alert(1)" + ut\assertFalse isSafeUrl "ftp://x/y" + + -- whitespace and control characters (the levers for command injection) are rejected + isSafeUrl_rejectsWhitespaceAndControl: (ut) -> + ut\assertFalse isSafeUrl "https://x/a b" + ut\assertFalse isSafeUrl "https://x/a\nrm -rf ~" + ut\assertFalse isSafeUrl "https://x/a\tb" + + -- non-string input is rejected + isSafeUrl_rejectsNonString: (ut) -> + ut\assertFalse isSafeUrl nil + ut\assertFalse isSafeUrl 42 + + -- the URL is single-quoted, with embedded quotes closed/escaped/reopened, so /bin/sh sees one argument + buildPosixOpenCommand_escapesForShell: (ut) -> + ut\assertEquals buildPosixOpenCommand("xdg-open", "https://x/a"), + "xdg-open 'https://x/a' >/dev/null 2>&1" + ut\assertEquals buildPosixOpenCommand("open", "https://x/a'b"), + "open 'https://x/a'\\''b' >/dev/null 2>&1" + + -- the public open() enforces the gate: an unsafe URL is refused (nil + error) before any opener runs + open_refusesUnsafeUrl: (ut) -> + ok, err = openUrl.open "file:///etc/passwd" + ut\assertNil ok + ut\assertNotNil err + + _order: { + "isSafeUrl_acceptsHttp", "isSafeUrl_rejectsOtherSchemes", "isSafeUrl_rejectsWhitespaceAndControl" + "isSafeUrl_rejectsNonString", "buildPosixOpenCommand_escapesForShell", "open_refusesUnsafeUrl" + } + } diff --git a/modules/l0/MoonCats.moon b/modules/l0/MoonCats.moon new file mode 100644 index 0000000..d3ce042 --- /dev/null +++ b/modules/l0/MoonCats.moon @@ -0,0 +1,120 @@ +DependencyControl = require "l0.DependencyControl" +constants = require "l0.DependencyControl.Constants" +Parser = require "l0.MoonCats.Parser" +Emitter = require "l0.MoonCats.Emitter" +DocRenderer = require "l0.MoonCats.DocRenderer" +Diagnostics = require "l0.MoonCats.Diagnostics" + +FindingCode = Diagnostics.FindingCode + +version = DependencyControl { + name: "MoonCATS" + version: "0.1.0" + description: "Extracts LuaCATS annotations from MoonScript sources into LuaLS type definitions." + author: "line0" + moduleName: "l0.MoonCats" + url: "https://github.com/TypesettingTools/DependencyControl" + feed: constants.DEPCTRL_FEED_URL +} + +---One MoonScript module handed to an extraction run. +---@class MoonCatsSource +---@field requireId string Require identifier the module resolves under. +---@field source string The module's MoonScript source text. + +---The outcome of an extraction run. +---@class MoonCatsExtractionResult +---@field definitions {requireId: string, text: string}[] One definition file per successfully parsed module, in input order. +---@field diagnostics MoonCatsDiagnostics All findings from parsing and emission. + +---Builds the cross-module symbol context the emitter resolves references against. +---@param irs table[] The run's parsed module IRs. +---@return MoonCatsPackageSymbols packageSymbols The run-wide type-name and alias tables, empty when no module resolved a type. +buildPackageSymbols = (irs) -> + packageSymbols = {typeNameByRequireId: {}, aliases: {}} + for ir in *irs + typeName = switch ir.export.kind + when Parser.ExportKind.Class + ir.export.class and ir.export.class.typeName + when Parser.ExportKind.Table + -- table modules declare a synthesized class named after their require identifier + ir.requireId + packageSymbols.typeNameByRequireId[ir.requireId] = typeName if typeName + for name in pairs ir.aliases + packageSymbols.aliases[name] = true + packageSymbols + +---Extracts LuaCATS annotations from MoonScript sources into LuaLS .d.lua type-definition files +---and rendered API documentation. +---@class MoonCats +---@field private __parser MoonCatsParser +---@field private __emitter MoonCatsEmitter +---@field private __docRenderer MoonCatsDocRenderer +class MoonCats + ---@type MoonCatsParser + @Parser = Parser + ---@type MoonCatsEmitter + @Emitter = Emitter + ---@type MoonCatsDocRenderer + @DocRenderer = DocRenderer + ---@type MoonCatsDiagnostics + @Diagnostics = Diagnostics + + ---Creates an extractor. + new: => + @__parser = Parser! + @__emitter = Emitter! + @__docRenderer = DocRenderer! + + ---Parses every source into its IR and builds the run-wide symbol context. + ---@private + ---@param sources MoonCatsSource[] + ---@param diagnostics MoonCatsDiagnostics + ---@return table[] irs The IRs of the modules that parsed, in input order. + ---@return MoonCatsPackageSymbols packageSymbols The run-wide type-name and alias context those IRs resolve against. + __parseAll: (sources, diagnostics) => + irs = {} + for moduleSource in *sources + ir, err = @__parser\parse moduleSource.source, moduleSource.requireId, diagnostics + if ir + table.insert irs, ir + else + diagnostics\add FindingCode.ParseFailure, moduleSource.requireId, nil, err + irs, buildPackageSymbols irs + + ---Extracts type-definition files for a set of modules, resolving cross-module references + ---against every module in the set. + ---@param sources MoonCatsSource[] The modules to extract, each with its require identifier. + ---@return MoonCatsExtractionResult result Definitions for the modules that parsed, plus all findings. + extractPackage: (sources) => + diagnostics = Diagnostics! + irs, packageSymbols = @__parseAll sources, diagnostics + definitions = for ir in *irs + {requireId: ir.requireId, text: @__emitter\emit ir, packageSymbols, diagnostics} + {:definitions, :diagnostics} + + ---Renders API documentation pages for a set of modules, resolving cross-module + ---references and type links against every module in the set. + ---@param sources MoonCatsSource[] The modules to document, each with its require identifier. + ---@param opts? MoonCatsDocOptions + ---@return MoonCatsDocResult result Pages, index, and site scaffolding. + ---@return MoonCatsDiagnostics diagnostics All findings from the parse pass. + renderDocs: (sources, opts) => + diagnostics = Diagnostics! + irs, packageSymbols = @__parseAll sources, diagnostics + result = @__docRenderer\render irs, packageSymbols, opts + result, diagnostics + + ---Extracts the type-definition file for a single module without cross-module context. + ---@param source string The module's MoonScript source text. + ---@param requireId string Require identifier the module resolves under. + ---@return string? definition The definition text, or nil when the module fails to parse. + ---@return MoonCatsDiagnostics diagnostics All findings from the run. + extractModule: (source, requireId) => + result = @extractPackage {{:requireId, :source}} + definition = result.definitions[1] + definition and definition.text, result.diagnostics + +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +UnitTestSuite\withTestExports MoonCats, {:buildPackageSymbols} +return version\register MoonCats diff --git a/modules/l0/MoonCats/Diagnostics.moon b/modules/l0/MoonCats/Diagnostics.moon new file mode 100644 index 0000000..a4cc126 --- /dev/null +++ b/modules/l0/MoonCats/Diagnostics.moon @@ -0,0 +1,140 @@ +Enum = require "l0.DependencyControl.Enum" + +---@alias MoonCatsSeverity +---| "error" # Error: fails a check run; the source or the extractor needs fixing +---| "warning" # Warning: a construct the extractor degraded gracefully; the definition may be imprecise +---| "info" # Info: a benign note about a fallback taken during emission +Severity = Enum "MoonCatsSeverity", { + Error: "error" + Warning: "warning" + Info: "info" +} + +---@alias MoonCatsFindingCode +---| "E-PARSE" # ParseFailure: the MoonScript source failed to parse +---| "E-MISSING-DOC" # MissingDoc: a public function member carries no annotation block +---| "E-PARAM-NAME" # ParamNameMismatch: a documented parameter name differs from the signature +---| "E-PARAM-COUNT" # ParamCountMismatch: the @param tags do not match the signature's parameter count +---| "E-MISSING-RETURN" # MissingReturn: the body explicitly returns a value but no @return is documented +---| "E-EMIT" # EmitFailure: the emitted definition is not loadable Lua +---| "W-COMPUTED-KEY" # ComputedKeySkipped: a member with a computed table key was skipped +---| "W-UNRESOLVED" # UnresolvedReference: an identifier could not be resolved and was typed as any +---| "W-ACCESSOR-FIELD" # AccessorFieldMissing: a computed property has no matching @field on its class +---| "W-CLASS-NAME" # ClassNameMismatch: the @class annotation names a different class than the statement +---| "W-NO-EXPORT" # NoExport: the module's export could not be determined +---| "I-EXTERNAL-REQUIRE" # ExternalRequire: a required module is outside the extraction run and was typed as any +---| "I-NESTED-TABLE" # NestedTableFlattened: a nested table value was flattened to the plain table type +---| "I-CTOR-FALLBACK" # CtorOverloadFallback: a subclass without its own constructor got a fun(...) overload +---| "I-DUPLICATE-MEMBER" # DuplicateMemberFolded: a same-name member was folded into the primary declaration's overloads +FindingCode = Enum "MoonCatsFindingCode", { + ParseFailure: "E-PARSE" + MissingDoc: "E-MISSING-DOC" + ParamNameMismatch: "E-PARAM-NAME" + ParamCountMismatch: "E-PARAM-COUNT" + MissingReturn: "E-MISSING-RETURN" + EmitFailure: "E-EMIT" + ComputedKeySkipped: "W-COMPUTED-KEY" + UnresolvedReference: "W-UNRESOLVED" + AccessorFieldMissing: "W-ACCESSOR-FIELD" + ClassNameMismatch: "W-CLASS-NAME" + NoExport: "W-NO-EXPORT" + ExternalRequire: "I-EXTERNAL-REQUIRE" + NestedTableFlattened: "I-NESTED-TABLE" + CtorOverloadFallback: "I-CTOR-FALLBACK" + DuplicateMemberFolded: "I-DUPLICATE-MEMBER" +} + +msgs = { + add: { + badCode: "Invalid finding code '%s'." + templates: { + ["E-PARSE"]: "failed to parse MoonScript source: %s" + ["E-MISSING-DOC"]: "public member '%s' has no annotation block" + ["E-PARAM-NAME"]: "@param %d is named '%s' but the signature says '%s'" + ["E-PARAM-COUNT"]: "%d @param tag(s) document a %d-parameter signature of '%s'" + ["E-MISSING-RETURN"]: "'%s' explicitly returns a value but documents no @return" + ["E-EMIT"]: "emitted definition is not valid Lua: %s" + ["W-COMPUTED-KEY"]: "computed key skipped in '%s'" + ["W-UNRESOLVED"]: "cannot resolve '%s'; typed as any" + ["W-ACCESSOR-FIELD"]: "computed property '%s' has no matching @field on class '%s'" + ["W-CLASS-NAME"]: "@class annotation names '%s' but the class statement says '%s'" + ["W-NO-EXPORT"]: "cannot determine the module's export" + ["I-EXTERNAL-REQUIRE"]: "'%s' resolves outside this extraction run; typed as any" + ["I-NESTED-TABLE"]: "nested table '%s' flattened to the plain table type" + ["I-CTOR-FALLBACK"]: "'%s' extends a parent without a constructor of its own; overload emitted as fun(...)" + ["I-DUPLICATE-MEMBER"]: "'%s' is defined more than once; secondary definitions folded into overloads" + } + } +} + +severityByCodePrefix = { + E: Severity.Error + W: Severity.Warning + I: Severity.Info +} + +---A single finding produced while extracting definitions. +---@class MoonCatsFinding +---@field severity MoonCatsSeverity +---@field code MoonCatsFindingCode +---@field requireId string Require identifier of the module the finding is about. +---@field line? integer 1-based source line the finding anchors to. +---@field message string Fully formatted human-readable description. + +---Collects, classifies, and formats the findings produced while extracting LuaCATS definitions. +---@class MoonCatsDiagnostics +---@field findings MoonCatsFinding[] All findings recorded so far, in insertion order. +class MoonCatsDiagnostics + ---@type Enum + @Severity = Severity + ---@type Enum + @FindingCode = FindingCode + + ---Creates an empty findings collection. + new: => + @findings = {} + + ---Records a finding against a module; its severity derives from the finding code. + ---@param code MoonCatsFindingCode + ---@param requireId string Require identifier of the module the finding is about. + ---@param line? integer 1-based source line the finding anchors to. + ---@param ... any Values for the finding message's format string. + ---@return MoonCatsFinding finding The recorded finding. + add: (code, requireId, line, ...) => + template = msgs.add.templates[code] + error msgs.add.badCode\format tostring code unless template + finding = { + severity: severityByCodePrefix[code\sub 1, 1] + :code, :requireId, :line + message: template\format ... + } + table.insert @findings, finding + finding + + ---Reports whether any finding is severe enough to fail a check run. + ---@return boolean failed True when at least one error-severity finding was recorded. + hasCheckFailures: => + for finding in *@findings + return true if finding.severity == Severity.Error + false + + ---Returns the number of findings recorded per severity. + ---@return table<MoonCatsSeverity, integer> counts Keyed by severity value; zero for severities without findings. + getCounts: => + counts = {severity, 0 for severity in *Severity.values} + counts[finding.severity] += 1 for finding in *@findings + counts + + ---Formats all findings as one line each, sorted by module, line, and code. + ---@return string report The formatted findings joined with newlines; empty when there are none. + format: => + sorted = [finding for finding in *@findings] + table.sort sorted, (a, b) -> + return a.requireId < b.requireId if a.requireId != b.requireId + lineA, lineB = a.line or 0, b.line or 0 + return lineA < lineB if lineA != lineB + a.code < b.code + lines = for finding in *sorted + location = finding.line and "#{finding.requireId}:#{finding.line}" or finding.requireId + "#{finding.severity} #{location} [#{finding.code}] #{finding.message}" + table.concat lines, "\n" diff --git a/modules/l0/MoonCats/DocRenderer.moon b/modules/l0/MoonCats/DocRenderer.moon new file mode 100644 index 0000000..a53919c --- /dev/null +++ b/modules/l0/MoonCats/DocRenderer.moon @@ -0,0 +1,736 @@ +Parser = require "l0.MoonCats.Parser" +annotations = require "l0.MoonCats.annotations" + +{:MemberKind, :ExportKind, :SymbolKind, :ValueKind, :SegmentKind} = Parser +{:collectBlockTags, :parseFieldTag, :parseAliasVariants, :proseLines, :blockTagText + :blockDocumentsFunction, :blockOwnType, :flattenBlockProse + :inferLiteralType, :inferExpressionType} = annotations + +---Options controlling a documentation render. +---@class MoonCatsDocOptions +---@field includePrivate? boolean Render private members with a badge instead of omitting them (default false). +---@field siteName? string Site title used by the index page and scaffolding (default "API Documentation"). +---@field site? string Scaffold flavor to emit: "mkdocs" (default), "mdbook", or "none". +---@field packages? table<string, {name: string, description?: string, version?: string, modules: string[]}> Feed package info keyed by namespace, used to group the index page. + +---One rendered documentation file. +---@class MoonCatsDocPage +---@field path string File path relative to the output root; module and index pages live under docs/. +---@field title string +---@field text string The page's markdown. + +---The outcome of a documentation render. +---@class MoonCatsDocResult +---@field pages MoonCatsDocPage[] One page per module, sorted by require identifier. +---@field indexPage MoonCatsDocPage +---@field scaffold MoonCatsDocPage[] Site-generator scaffolding files; empty for site "none". + +luaKeywords = { + "and": true, "break": true, "do": true, "else": true, "elseif": true, "end": true + "false": true, "for": true, "function": true, "if": true, "in": true, "local": true + "nil": true, "not": true, "or": true, "repeat": true, "return": true, "then": true + "true": true, "until": true, "while": true + -- MoonScript additions + "class": true, "export": true, "import": true, "switch": true, "when": true + "unless": true, "using": true, "with": true, "extends": true, "super": true +} + +builtinTypes = { + "any": true, "boolean": true, "string": true, "number": true, "integer": true + "table": true, "function": true, "nil": true, "true": true, "false": true + "self": true, "fun": true, "userdata": true, "lightuserdata": true, "thread": true +} + +---Derives the receiver variable used in instance-call examples from a class name. +---@param typeName string +---@return string receiver +receiverName = (typeName) -> + leaf = typeName\match("([%w_]+)$") or typeName + name = leaf\sub(1, 1)\lower! .. leaf\sub 2 + luaKeywords[name] and "the" .. leaf or name + +---Escapes a description for use inside a markdown table cell. +---@param text? string +---@return string cellText +cellText = (text) -> + return "" unless text + (text\gsub("|", "\\|")\gsub "\n", " ") + +---Sanitizes a require identifier's leaf into an identifier for module-value examples. +---@param requireId string +---@return string name +moduleVarName = (requireId) -> + leaf = requireId\match("([^%.]+)$") or requireId + name = leaf\gsub "[^%w_]", "_" + name = "_" .. name if name\match "^%d" + name + +---Renders a type expression with cross-links to known type anchors. Complex expressions +---(function types, inline tables, generics) stay as a single code span. +---@param typeText? string +---@param state table +---@return string rendered The markdown for the type, with known names linked; empty for a nil or empty input. +linkifyType = (typeText, state) -> + return "" unless typeText and #typeText > 0 + if typeText\find("fun(", 1, true) or typeText\find("{", 1, true) or typeText\find("<", 1, true) + return "`#{typeText}`" + out = {} + pos = 1 + while pos <= #typeText + startPos, endPos = typeText\find "[%w_%.]+", pos + unless startPos + table.insert out, (cellText typeText\sub pos) + break + if startPos > pos + table.insert out, (cellText typeText\sub pos, startPos - 1) + token = typeText\sub startPos, endPos + target = state.linkIndex[token] + if target + href = target.page == state.currentPage and "#" .. target.anchor or "#{target.page}##{target.anchor}" + table.insert out, "[#{token}](#{href})" + else + table.insert out, "`#{token}`" + pos = endPos + 1 + table.concat out + +---Builds the moon- and Lua-form call lines shown in a signature block. +---@param callee string The receiver-and-name prefix, e.g. "timer\\start" and "timer:start". +---@param calleeLua string +---@param paramNames string[] +---@param assignTo? string Left-hand side for constructor examples. +---@return string moonLine +---@return string luaLine +callForms = (callee, calleeLua, paramNames, assignTo) -> + args = table.concat paramNames, ", " + moonLine = #paramNames > 0 and "#{callee} #{args}" or "#{callee}!" + luaLine = "#{calleeLua}(#{args})" + if assignTo + moonLine = "#{assignTo} = #{moonLine}" + luaLine = "local #{assignTo} = #{luaLine}" + moonLine, luaLine + +---Emits a block's prose, upgrading markdown indented code blocks into fenced moonscript blocks +---so the site highlights them. An indented run is a code block only when a blank line precedes +---it, so hanging-indent continuation lines inside a paragraph are left as prose. A trailing blank +---line is appended when anything was emitted. +---@param out string[] +---@param blockLines? string[] +---@return boolean emitted Whether any prose was written. +pushProse = (out, blockLines) -> + lines = proseLines blockLines + return false if #lines == 0 + i = 1 + while i <= #lines + line = lines[i] + blankBefore = i == 1 or lines[i - 1]\match("^%s*$") != nil + if blankBefore and line\match "^ " + -- collect the indented run (interior blank lines belong to the example) + last = i + probe = i + while probe <= #lines and (lines[probe]\match("^ ") or lines[probe]\match "^%s*$") + last = probe if not lines[probe]\match "^%s*$" + probe += 1 + table.insert out, "```moonscript" + for exampleIndex = i, last + table.insert out, (lines[exampleIndex]\gsub "^ ", "") + table.insert out, "```" + i = last + 1 + else + table.insert out, line + i += 1 + table.insert out, "" + true + +---Emits a fenced signature block with aligned MoonScript and Lua call forms. +---@param out string[] +---@param moonLine string +---@param luaLine string +pushSignature = (out, moonLine, luaLine) -> + width = math.max #moonLine, #luaLine + pad = (line) -> line .. (" ")\rep width - #line + table.insert out, "```lua" + table.insert out, "#{pad moonLine} -- MoonScript" + table.insert out, "#{pad luaLine} -- Lua" + table.insert out, "```" + table.insert out, "" + +---Resolves the documented type of a plain-data member, chasing identifier references +---through the module's and run's symbol tables. +---@param state table +---@param ir MoonCatsModuleIR +---@param cls? MoonCatsClassIR +---@param member table +---@return string typeText Falls back to "any" when the type cannot be resolved. +resolveDataType = (state, ir, cls, member) -> + ownType = blockOwnType member.block + return ownType if ownType + return "#{member.enum.name}Enum" if member.enum + valueInfo = member.valueInfo + return "any" unless valueInfo + + chaseRef = (name, depth = 0) -> + return nil if depth > 4 or name == nil + sym = (cls and cls.localSymbols[name]) or ir.symbols[name] + return nil unless sym + switch sym.kind + when SymbolKind.Class then sym.class.typeName + when SymbolKind.Require then state.typeNameByRequireId[sym.requireId] + when SymbolKind.Enum then sym.enum.exportedAs and "#{sym.enum.name}Enum" or nil + when SymbolKind.Reference then chaseRef sym.target, depth + 1 + when SymbolKind.Literal + sym.literalKind != "nil" and inferLiteralType(sym.token, sym.literalKind) or nil + else nil + + switch valueInfo.kind + when ValueKind.Literal + valueInfo.literalKind == "nil" and "any" or inferLiteralType valueInfo.token, valueInfo.literalKind + when ValueKind.Reference + chaseRef(valueInfo.refName) or "any" + when ValueKind.Call + chaseRef(valueInfo.baseName) or "any" + when ValueKind.Table then "table" + when ValueKind.Expression then inferExpressionType valueInfo.node + else "any" + +---Resolves a member holding a bare reference to a local function into a renderable function +---member carrying the local's signature and annotation block. +---@param ir MoonCatsModuleIR +---@param cls? MoonCatsClassIR +---@param member table +---@return table? fnMember Nil when the member is not such a reference. +resolveFunctionMember = (ir, cls, member) -> + valueInfo = member.valueInfo + return nil unless valueInfo and valueInfo.kind == ValueKind.Reference + return nil if blockDocumentsFunction member.block + sym = (cls and cls.localSymbols[valueInfo.refName]) or ir.symbols[valueInfo.refName] + return nil unless sym and sym.kind == SymbolKind.Function + { + name: member.name, line: member.line + block: member.block or sym.block + params: sym.params + isPrivate: member.isPrivate or member.name\match("^__") != nil + } + +---Reports whether a member should be hidden as private under the given options. +---@param member table +---@param state table +---@return boolean hidden +isHiddenPrivate = (member, state) -> + return false if state.includePrivate + (member.isPrivate or (member.name and member.name\match("^__") != nil)) and true or false + +---Formats a member heading, badging private members when they are rendered at all. +---@param name string +---@param anchor string +---@param level string Heading marker, e.g. "####". +---@param isPrivate? boolean +---@return string heading +memberHeading = (name, anchor, level, isPrivate) -> + badge = isPrivate and " 🔒" or "" + "#{level} #{name}#{badge} <a name=\"#{anchor}\"></a>" + +---Renders the full documentation section for one function-like member. +---@param out string[] +---@param state table +---@param member table +---@param moonCallee string +---@param luaCallee string +---@param anchorPrefix string +renderFunctionMember = (out, state, member, moonCallee, luaCallee, anchorPrefix) -> + table.insert out, memberHeading member.name, "#{anchorPrefix}.#{member.name}", "####", member.isPrivate + table.insert out, "" + + deprecated = blockTagText member.block, "deprecated" + if deprecated + table.insert out, "> **Deprecated**#{#deprecated > 0 and " — " .. deprecated or ""}" + table.insert out, "" + + tags, returns = collectBlockTags member.block + paramNames = if member.params + [param.name for param in *member.params] + else + [tag.name for tag in *tags] + moonLine, luaLine = callForms moonCallee, luaCallee, paramNames + pushSignature out, moonLine, luaLine + + pushProse out, member.block + + if #tags > 0 + table.insert out, "| param | type | description |" + table.insert out, "|-------|------|-------------|" + for tag in *tags + optional = tag.optional and "?" or "" + table.insert out, "| #{tag.name}#{optional} | #{linkifyType tag.type, state} | #{cellText tag.description} |" + table.insert out, "" + + if #returns > 0 + table.insert out, "**Returns:**" + table.insert out, "" + for returnTag in *returns + label = returnTag.name and "`#{returnTag.name}` " or "" + description = returnTag.description and " — #{cellText returnTag.description}" or "" + table.insert out, "- #{label}#{linkifyType returnTag.type, state}#{description}" + table.insert out, "" + + see = blockTagText member.block, "see" + if see and #see > 0 + table.insert out, "See also: `#{see}`" + table.insert out, "" + +---Renders a fields table from prepared row records. +---@param out string[] +---@param state table +---@param rows {name: string, typeText: string, description?: string, isPrivate?: boolean}[] +renderFieldsTable = (out, state, rows) -> + return if #rows == 0 + table.insert out, "| field | type | description |" + table.insert out, "|-------|------|-------------|" + for row in *rows + badge = row.isPrivate and " 🔒" or "" + table.insert out, "| #{row.name}#{badge} | #{linkifyType row.typeText, state} | #{cellText row.description} |" + table.insert out, "" + +---Renders an enum's member table, joining descriptions from its alias's variant lines. +---@param out string[] +---@param state table +---@param enum MoonCatsEnumIR +---@param block? string[] The exporting member's annotation block, for lead prose. +---@param anchorPrefix string +renderEnum = (out, state, enum, block, anchorPrefix) -> + table.insert out, memberHeading enum.name, "#{anchorPrefix}.#{enum.name}", "####" + table.insert out, "" + pushProse out, block + + variantsByKey, variantsByValue = {}, {} + aliasSegment = state.aliasSegmentsByName[enum.name] + if aliasSegment + for variant in *parseAliasVariants aliasSegment.lines + variantsByKey[variant.key] = variant if variant.key + variantsByValue[variant.value] = variant + + table.insert out, "| member | value | description |" + table.insert out, "|--------|-------|-------------|" + for enumMember in *enum.members + variant = variantsByKey[enumMember.key] or variantsByValue[enumMember.literal] + description = variant and variant.description or nil + table.insert out, "| #{enumMember.key} | `#{enumMember.literal}` | #{cellText description} |" + table.insert out, "" + +---Renders a standalone alias or annotation-only class segment in a module's Types section. +---@param out string[] +---@param state table +---@param segment MoonCatsSegment +renderSegment = (out, state, segment) -> + return unless segment.name + table.insert out, memberHeading segment.name, segment.name, "###" + table.insert out, "" + pushProse out, segment.lines + + if segment.kind == SegmentKind.Alias + variants = parseAliasVariants segment.lines + if #variants > 0 + table.insert out, "| value | description |" + table.insert out, "|-------|-------------|" + for variant in *variants + label = variant.key and "#{variant.key}: " or "" + table.insert out, "| `#{variant.value}` | #{label}#{cellText variant.description} |" + table.insert out, "" + else + rows = {} + for line in *segment.lines + fieldTag = parseFieldTag line + continue unless fieldTag + isPrivate = fieldTag.visibility == "private" or fieldTag.name\match("^__") != nil + continue if isPrivate and not state.includePrivate + optional = fieldTag.optional and "?" or "" + table.insert rows, { + name: fieldTag.name .. optional, typeText: fieldTag.type + description: fieldTag.description, :isPrivate + } + renderFieldsTable out, state, rows + +---Splits a class's members into renderable groups, dropping hidden privates and duplicate +---definitions (the first documented one wins). +---@param state table +---@param ir MoonCatsModuleIR +---@param cls MoonCatsClassIR +---@return table groups Lists: ctor, instanceMethods, classMethods, dataFields, enums. +groupClassMembers = (state, ir, cls) -> + groups = {ctor: nil, instanceMethods: {}, classMethods: {}, dataFields: {}, enums: {}} + seen = {} + byName = {} + for member in *cls.members + continue if member.kind == MemberKind.Metamethod + byName[member.name] or= {} + table.insert byName[member.name], member + + pickPrimary = (name) -> + group = byName[name] + return group[1] if #group == 1 + for candidate in *group + return candidate if candidate.block + group[1] + + for member in *cls.members + continue if member.kind == MemberKind.Metamethod + continue if seen[member.name] + primary = pickPrimary member.name + continue if member != primary + seen[member.name] = true + continue if isHiddenPrivate primary, state + + switch primary.kind + when MemberKind.Constructor + groups.ctor = primary + when MemberKind.Method + table.insert groups.instanceMethods, primary + when MemberKind.StaticMethodFat, MemberKind.StaticMethodThin + table.insert groups.classMethods, primary + when MemberKind.EnumExport + table.insert groups.enums, primary + when MemberKind.AccessorProperty + nil -- documented via the class block's @field lines + when MemberKind.StaticData, MemberKind.InstanceDefault + fnMember = resolveFunctionMember ir, cls, primary + if fnMember + fnMember.kind = MemberKind.StaticMethodThin + table.insert groups.classMethods, fnMember unless isHiddenPrivate fnMember, state + elseif blockDocumentsFunction primary.block + table.insert groups.classMethods, primary + else + table.insert groups.dataFields, primary + + for aug in *ir.augmentations + continue unless aug.targetName == cls.name + continue if seen[aug.name] or isHiddenPrivate aug, state + seen[aug.name] = true + augEnum = aug.enum + if not augEnum and aug.valueInfo.kind == ValueKind.Reference + sym = ir.symbols[aug.valueInfo.refName] + if sym and sym.kind == SymbolKind.Enum and sym.enum.exportedAs == aug.name + augEnum = sym.enum + if augEnum + table.insert groups.enums, {name: aug.name, block: aug.block, enum: augEnum} + else + fnMember = resolveFunctionMember ir, cls, aug + if fnMember + fnMember.kind = MemberKind.StaticMethodThin + table.insert groups.classMethods, fnMember + elseif aug.valueInfo.kind == ValueKind.Function or blockDocumentsFunction aug.block + table.insert groups.classMethods, { + name: aug.name, block: aug.block, params: aug.params + kind: MemberKind.StaticMethodThin + isPrivate: aug.name\match("^__") != nil + } + else + table.insert groups.dataFields, aug + + groups + +---Renders the full documentation section for one class. +---@param out string[] +---@param state table +---@param ir MoonCatsModuleIR +---@param cls MoonCatsClassIR +renderClass = (out, state, ir, cls) -> + typeName = cls.typeName + table.insert out, "## #{typeName} <a name=\"#{typeName}\"></a>" + table.insert out, "" + pushProse out, cls.block + + groups = groupClassMembers state, ir, cls + receiver = receiverName typeName + + if groups.ctor + table.insert out, "### Constructor <a name=\"#{typeName}.new\"></a>" + table.insert out, "" + ctor = groups.ctor + tags, _ = collectBlockTags ctor.block + paramNames = [param.name for param in *ctor.params or {}] + moonLine, luaLine = callForms typeName, typeName, paramNames, receiver + pushSignature out, moonLine, luaLine + pushProse out, ctor.block + if #tags > 0 + table.insert out, "| param | type | description |" + table.insert out, "|-------|------|-------------|" + for tag in *tags + optional = tag.optional and "?" or "" + table.insert out, "| #{tag.name}#{optional} | #{linkifyType tag.type, state} | #{cellText tag.description} |" + table.insert out, "" + + if #groups.instanceMethods > 0 + table.insert out, "### Instance methods" + table.insert out, "" + for member in *groups.instanceMethods + renderFunctionMember out, state, member, + "#{receiver}\\#{member.name}", "#{receiver}:#{member.name}", typeName + + if #groups.classMethods > 0 + table.insert out, "### Class methods" + table.insert out, "" + for member in *groups.classMethods + if member.kind == MemberKind.StaticMethodFat + renderFunctionMember out, state, member, + "#{typeName}\\#{member.name}", "#{typeName}:#{member.name}", typeName + else + renderFunctionMember out, state, member, + "#{typeName}.#{member.name}", "#{typeName}.#{member.name}", typeName + + fieldRows = {} + for line in *cls.block or {} + fieldTag = parseFieldTag line + continue unless fieldTag + isPrivate = fieldTag.visibility == "private" or fieldTag.name\match("^__") != nil + continue if isPrivate and not state.includePrivate + optional = fieldTag.optional and "?" or "" + table.insert fieldRows, { + name: fieldTag.name .. optional, typeText: fieldTag.type + description: fieldTag.description, :isPrivate + } + for member in *groups.dataFields + table.insert fieldRows, { + name: member.name + typeText: resolveDataType state, ir, cls, member + description: flattenBlockProse member.block + isPrivate: member.isPrivate + } + if #fieldRows > 0 + table.insert out, "### Fields" + table.insert out, "" + renderFieldsTable out, state, fieldRows + + if #groups.enums > 0 + table.insert out, "### Enums" + table.insert out, "" + for member in *groups.enums + renderEnum out, state, member.enum, member.block, typeName + +---Renders a table-module's fields and functions onto its module value. +---@param out string[] +---@param state table +---@param ir MoonCatsModuleIR +renderTableModule = (out, state, ir) -> + varName = ir.export.name and ir.export.name\match("^[%w_]+$") and ir.export.name or moduleVarName ir.requireId + fieldRows = {} + fnMembers = {} + + collect = (entry) -> + return if isHiddenPrivate entry, state + fnMember = resolveFunctionMember ir, nil, entry + if fnMember + table.insert fnMembers, fnMember + elseif (entry.valueInfo and entry.valueInfo.kind == ValueKind.Function) or blockDocumentsFunction entry.block + table.insert fnMembers, entry + else + table.insert fieldRows, { + name: entry.name + typeText: resolveDataType state, ir, nil, entry + description: flattenBlockProse entry.block + isPrivate: entry.isPrivate + } + + collect field for field in *ir.export.fields or {} + for aug in *ir.augmentations + collect aug if aug.targetName == ir.export.name or aug.targetName == varName + + if #fnMembers > 0 + table.insert out, "## Functions" + table.insert out, "" + for member in *fnMembers + renderFunctionMember out, state, member, + "#{varName}.#{member.name}", "#{varName}.#{member.name}", varName + + if #fieldRows > 0 + table.insert out, "## Fields" + table.insert out, "" + renderFieldsTable out, state, fieldRows + +---Renders a function-module's single exported function. +---@param out string[] +---@param state table +---@param ir MoonCatsModuleIR +renderFunctionModule = (out, state, ir) -> + exportIR = ir.export + member = { + name: exportIR.name, block: exportIR.block + params: exportIR.params, isPrivate: false + } + renderFunctionMember out, state, member, exportIR.name, exportIR.name, ir.requireId + +---Renders one module's documentation page. +---@param state table +---@param ir MoonCatsModuleIR +---@return MoonCatsDocPage page +renderModulePage = (state, ir) -> + state.currentPage = "#{ir.requireId}.md" + out = {} + table.insert out, "# #{ir.requireId}" + table.insert out, "" + varName = if ir.export.kind == ExportKind.Class and ir.export.class + ir.export.class.name + elseif ir.export.kind == ExportKind.Function + ir.export.name + else + moduleVarName ir.requireId + table.insert out, "```lua" + table.insert out, "#{varName} = require \"#{ir.requireId}\" -- MoonScript" + table.insert out, "local #{varName} = require(\"#{ir.requireId}\") -- Lua" + table.insert out, "```" + table.insert out, "" + + switch ir.export.kind + when ExportKind.Table + renderTableModule out, state, ir + when ExportKind.Function + renderFunctionModule out, state, ir + + for cls in *ir.classes + renderClass out, state, ir, cls + + namedSegments = [segment for segment in *ir.segments when segment.name] + if #namedSegments > 0 + table.insert out, "## Types" + table.insert out, "" + for segment in *namedSegments + renderSegment out, state, segment + + -- mkdocs requires the site config to sit beside its docs dir, not inside it, so pages go under docs/. + -- Cross-page links stay bare filenames because the pages are siblings. + { + path: "docs/#{ir.requireId}.md" + title: ir.requireId + text: table.concat(out, "\n") .. "\n" + } + +---Builds the run-wide index mapping type names to their page and anchor. +---@param state table +---@param irs MoonCatsModuleIR[] +buildLinkIndex = (state, irs) -> + for ir in *irs + page = "#{ir.requireId}.md" + for cls in *ir.classes + state.linkIndex[cls.typeName] = {:page, anchor: cls.typeName} + for segment in *ir.segments + continue unless segment.name + state.linkIndex[segment.name] or= {:page, anchor: segment.name} + state.aliasSegmentsByName[segment.name] or= segment if segment.kind == SegmentKind.Alias + for enum in *ir.enums + continue unless enum.exportedAs + owner = nil + for cls in *ir.classes + for member in *cls.members + owner = cls.typeName if member.enum == enum + anchor = owner and "#{owner}.#{enum.name}" or enum.name + enumClassName = "#{enum.name}Enum" + state.linkIndex[enumClassName] = {:page, :anchor} unless state.linkIndex[enumClassName] + +---Renders the index page grouping module pages by their feed package. +---@param state table +---@param irs MoonCatsModuleIR[] +---@param opts MoonCatsDocOptions +---@return MoonCatsDocPage page +renderIndexPage = (state, irs, opts) -> + out = {"# #{state.siteName}", ""} + requireIds = [ir.requireId for ir in *irs] + table.sort requireIds + covered = {} + + if opts.packages + namespaces = [namespace for namespace in pairs opts.packages] + table.sort namespaces + for namespace in *namespaces + pkg = opts.packages[namespace] + version = pkg.version and " `v#{pkg.version}`" or "" + table.insert out, "## #{pkg.name or namespace}#{version}" + table.insert out, "" + if pkg.description + table.insert out, pkg.description + table.insert out, "" + moduleIds = [id for id in *pkg.modules or {}] + table.sort moduleIds + for id in *moduleIds + covered[id] = true + table.insert out, "- [#{id}](#{id}.md)" + table.insert out, "" + + leftovers = [id for id in *requireIds when not covered[id]] + if #leftovers > 0 + if opts.packages + table.insert out, "## Other modules" + table.insert out, "" + for id in *leftovers + table.insert out, "- [#{id}](#{id}.md)" + table.insert out, "" + + {path: "docs/index.md", title: state.siteName, text: table.concat(out, "\n") .. "\n"} + +---Builds the site-generator scaffolding files for the chosen flavor. +---@param state table +---@param pages MoonCatsDocPage[] +---@param site string +---@return MoonCatsDocPage[] scaffold The config/nav files; empty for site "none". +buildScaffold = (state, pages, site) -> + -- nav and summary entries are relative to the docs dir, so they use bare filenames + fileName = (page) -> page.path\match "([^/]+)$" + switch site + when "mkdocs" + out = { + "site_name: #{state.siteName}" + "theme:" + " name: material" + -- mike maintains versions.json on the published site; Material shows its + -- version picker when present and hides it in unversioned/local serves + "extra:" + " version:" + " provider: mike" + "nav:" + " - Overview: index.md" + } + for page in *pages + table.insert out, " - #{page.title}: #{fileName page}" + {{path: "mkdocs.yml", title: "mkdocs.yml", text: table.concat(out, "\n") .. "\n"}} + when "mdbook" + summary = {"# Summary", "", "[Overview](index.md)", ""} + for page in *pages + table.insert summary, "- [#{page.title}](#{fileName page})" + book = { + "[book]" + "title = \"#{state.siteName}\"" + "src = \"docs\"" + } + { + {path: "docs/SUMMARY.md", title: "Summary", text: table.concat(summary, "\n") .. "\n"} + {path: "book.toml", title: "book.toml", text: table.concat(book, "\n") .. "\n"} + } + else + {} + +---Renders API documentation pages from parsed module IRs. +---@class MoonCatsDocRenderer +class MoonCatsDocRenderer + ---Renders one documentation page per module, plus an index page and site scaffolding. + ---@param irs MoonCatsModuleIR[] The run's parsed module IRs. + ---@param packageSymbols? MoonCatsPackageSymbols Cross-module symbol context from the parse pass. + ---@param opts? MoonCatsDocOptions + ---@return MoonCatsDocResult result + render: (irs, packageSymbols, opts = {}) => + state = { + includePrivate: opts.includePrivate or false + siteName: opts.siteName or "API Documentation" + typeNameByRequireId: packageSymbols and packageSymbols.typeNameByRequireId or {} + linkIndex: {} + aliasSegmentsByName: {} + } + buildLinkIndex state, irs + + sorted = [ir for ir in *irs] + table.sort sorted, (a, b) -> a.requireId < b.requireId + pages = [renderModulePage state, ir for ir in *sorted] + indexPage = renderIndexPage state, sorted, opts + scaffold = buildScaffold state, pages, opts.site or "mkdocs" + {:pages, :indexPage, :scaffold} + +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +return UnitTestSuite\withTestExports MoonCatsDocRenderer, { + :receiverName, :linkifyType, :moduleVarName, :callForms, :resolveDataType +} diff --git a/modules/l0/MoonCats/Emitter.moon b/modules/l0/MoonCats/Emitter.moon new file mode 100644 index 0000000..0ec960e --- /dev/null +++ b/modules/l0/MoonCats/Emitter.moon @@ -0,0 +1,606 @@ +Diagnostics = require "l0.MoonCats.Diagnostics" +Parser = require "l0.MoonCats.Parser" +annotations = require "l0.MoonCats.annotations" + +FindingCode = Diagnostics.FindingCode +{:MemberKind, :ExportKind, :SymbolKind, :ValueKind} = Parser +{:extractTypeText, :parseParamTag, :parseReturnTag, :collectBlockTags, :blockDocumentsFunction + :blockContainsPrivateTag, :flattenBlockProse, :blockOwnType, :guardFunType + :inferLiteralType, :inferExpressionType} = annotations + +---The cross-module context the emitter resolves identifier references against. +---@class MoonCatsPackageSymbols +---@field typeNameByRequireId table<string, string> LuaCATS type name each require identifier's export declares. +---@field aliases table<string, boolean> Names of @alias declarations anywhere in the extraction run. + +---Appends one line to the emission buffer. +---@param state table +---@param line string +push = (state, line) -> + table.insert state.out, line + +---Appends a block of annotation lines verbatim. +---@param state table +---@param blockLines? string[] +pushBlock = (state, blockLines) -> + if blockLines + for line in *blockLines + push state, line + +---Records a finding when a diagnostics collection is attached. +---@param state table +---@param code MoonCatsFindingCode +---@param line? integer +---@param ... any Message format values. +report = (state, code, line, ...) -> + state.diagnostics\add code, state.ir.requireId, line, ... if state.diagnostics + +---Collects the field names a class annotation block already declares. +---@param blockLines? string[] +---@return table<string, boolean> fieldNames +collectFieldNames = (blockLines) -> + names = {} + if blockLines + for line in *blockLines + name = line\match "^%-%-%-@field%s+([%w_]+)" + names[name] = true if name + names + +---Reports whether a member, field, or augmentation holds plain data — neither a function +---literal nor a block documenting a callable contract. +---@param member MoonCatsMemberIR|MoonCatsFieldIR|MoonCatsAugmentationIR +---@return boolean plainData +isPlainData = (member) -> + return false if member.valueInfo and member.valueInfo.kind == ValueKind.Function + not blockDocumentsFunction member.block + +---Sanitizes a require identifier's leaf into a Lua identifier for the module local. +---@param requireId string +---@return string name +moduleVarName = (requireId) -> + leaf = requireId\match("([^%.]+)$") or requireId + name = leaf\gsub "[^%w_]", "_" + name = "_" .. name if name\match "^%d" + name + +---Resolves an identifier to the LuaCATS type name of the value it holds. +---@param name string +---@param state table +---@param cls? MoonCatsClassIR Class scope to search before module scope. +---@param depth? integer Reference-chase depth guard. +---@return string? typeName Nil when the identifier cannot be resolved. +resolveRefType = (name, state, cls, depth = 0) -> + return nil if depth > 4 or name == nil + sym = (cls and cls.localSymbols[name]) or state.ir.symbols[name] + return nil unless sym + switch sym.kind + when SymbolKind.Class + sym.class.typeName + when SymbolKind.Require + typeName = state.packageSymbols.typeNameByRequireId[sym.requireId] + unless typeName + report state, FindingCode.ExternalRequire, nil, sym.requireId + typeName + when SymbolKind.Enum + sym.enum.exportedAs and "#{sym.enum.name}Enum" or nil + when SymbolKind.Reference + resolveRefType sym.target, state, cls, depth + 1 + when SymbolKind.Literal + sym.literalKind != "nil" and inferLiteralType(sym.token, sym.literalKind) or nil + else + nil + +---Derives the emitted type for a data member's value expression. +---@param valueInfo? MoonCatsValueInfo +---@param state table +---@param cls? MoonCatsClassIR +---@param name string Member name, for findings. +---@param line? integer +---@return string typeText Falls back to "any" with a finding when unresolvable. +typeFromValueInfo = (valueInfo, state, cls, name, line) -> + return "any" unless valueInfo + switch valueInfo.kind + when ValueKind.Literal + if valueInfo.literalKind == "nil" + "any" + else + inferLiteralType valueInfo.token, valueInfo.literalKind + when ValueKind.Reference + typeName = resolveRefType valueInfo.refName, state, cls + unless typeName + report state, FindingCode.UnresolvedReference, line, valueInfo.refName + typeName or "any" + when ValueKind.Call + typeName = resolveRefType valueInfo.baseName, state, cls + unless typeName + report state, FindingCode.UnresolvedReference, line, name + typeName or "any" + when ValueKind.Table + "table" + when ValueKind.Expression + inferExpressionType valueInfo.node + else + report state, FindingCode.UnresolvedReference, line, name + "any" + +---Emits a standalone alias or annotation-only class declaration verbatim. +---@param state table +---@param segment MoonCatsSegment +emitSegment = (state, segment) -> + pushBlock state, segment.lines + push state, "" + +---Builds the ---@overload line for a class from its constructor's signature and documented types. +---@param cls MoonCatsClassIR +---@param ctor? MoonCatsMemberIR +---@param state table +---@return string overloadLine +synthesizeOverload = (cls, ctor, state) -> + unless ctor + if cls.hasParent + report state, FindingCode.CtorOverloadFallback, cls.line, cls.typeName + return "---@overload fun(...): #{cls.typeName}" + return "---@overload fun(): #{cls.typeName}" + tags, _ = collectBlockTags ctor.block + typesByName = {tag.name, tag for tag in *tags} + parts = for param in *ctor.params + tag = typesByName[param.name] + paramType = guardFunType tag and tag.type or "any" + if param.isVararg + "...: #{paramType}" + else + optional = (tag and tag.optional or param.hasDefault) and "?" or "" + "#{param.name}#{optional}: #{paramType}" + "---@overload fun(#{table.concat parts, ", "}): #{cls.typeName}" + +---Synthesizes the @field line documenting a data member on its owner's class header. +---@param state table +---@param cls? MoonCatsClassIR Class scope for reference resolution. +---@param member MoonCatsMemberIR|MoonCatsFieldIR|MoonCatsAugmentationIR +---@return string fieldLine +synthesizeDataField = (state, cls, member) -> + fieldType = blockOwnType member.block + unless fieldType + fieldType = if member.enum + "#{member.enum.name}Enum" + else + typeFromValueInfo member.valueInfo, state, cls, member.name, member.line + visibility = (member.isPrivate or member.name\match("^__") != nil) and "private " or "" + description = flattenBlockProse member.block + description = " " .. description if #description > 0 + "---@field #{visibility}#{member.name} #{fieldType}#{description}" + +---Resolves the exported Enum an augmentation re-exports, when it does. +---@param state table +---@param aug MoonCatsAugmentationIR +---@return MoonCatsEnumIR? enum The re-exported enum, or nil when the augmentation is not an enum re-export. +augmentationEnum = (state, aug) -> + return aug.enum if aug.enum + return nil unless aug.valueInfo.kind == ValueKind.Reference + sym = state.ir.symbols[aug.valueInfo.refName] + sym and sym.kind == SymbolKind.Enum and sym.enum.exportedAs == aug.name and sym.enum or nil + +---Resolves a member holding a bare reference to a local function into an emittable function +---member carrying the local's signature and annotation block. Members with their own callable +---contract block are left alone — the block stays authoritative for them. +---@param state table +---@param cls? MoonCatsClassIR Class scope to search before module scope. +---@param member MoonCatsMemberIR|MoonCatsFieldIR|MoonCatsAugmentationIR +---@return MoonCatsMemberIR? fnMember Nil when the member is not an undocumented function reference. +resolveFunctionReference = (state, cls, member) -> + valueInfo = member.valueInfo + return nil unless valueInfo and valueInfo.kind == ValueKind.Reference + return nil if blockDocumentsFunction member.block + sym = (cls and cls.localSymbols[valueInfo.refName]) or state.ir.symbols[valueInfo.refName] + return nil unless sym and sym.kind == SymbolKind.Function + { + kind: sym.arrow == "fat" and MemberKind.StaticMethodFat or MemberKind.StaticMethodThin + name: member.name, line: member.line + block: member.block or sym.block + params: sym.params, hasExplicitValueReturn: sym.hasExplicitValueReturn + isPrivate: member.isPrivate or member.name\match("^__") != nil + arrow: sym.arrow + } + +---Appends @field lines for every plain-data member and augmentation an owner carries, skipping +---names its block already declares. +---@param state table +---@param cls? MoonCatsClassIR Class scope for reference resolution; nil for table modules. +---@param members table[] Data-bearing members/fields in source order. +---@param augmentations table[] Module-level augmentations owned by this class or table. +---@param fieldNames table<string, boolean> Declared field names; updated in place. +---@param fieldLines string[] Receives the synthesized lines. +collectDataFields = (state, cls, members, augmentations, fieldNames, fieldLines) -> + dataKinds = { + [MemberKind.StaticData]: true + [MemberKind.InstanceDefault]: true + [MemberKind.EnumExport]: true + } + for member in *members + -- table-module fields carry no member kind; class members filter to the data kinds + continue unless member.kind == nil or dataKinds[member.kind] + continue if fieldNames[member.name] or not isPlainData member + -- references to local functions emit as functions at their position, not as fields + continue if resolveFunctionReference state, cls, member + table.insert fieldLines, synthesizeDataField state, cls, member + fieldNames[member.name] = true + for aug in *augmentations + continue if fieldNames[aug.name] or not isPlainData aug + continue if resolveFunctionReference state, cls, aug + aug.enum = augmentationEnum state, aug + table.insert fieldLines, synthesizeDataField state, cls, aug + fieldNames[aug.name] = true + +---Checks a documented function member's tags against its real signature. +---@param state table +---@param member MoonCatsMemberIR +validateFunctionContract = (state, member) -> + unless member.block + unless member.isPrivate + report state, FindingCode.MissingDoc, member.line, member.name + return + return unless member.params + tags, returns = collectBlockTags member.block + if #tags != #member.params + report state, FindingCode.ParamCountMismatch, member.line, #tags, #member.params, member.name + else + for i, param in ipairs member.params + if tags[i].name != param.name + report state, FindingCode.ParamNameMismatch, member.line, i, tags[i].name, param.name + break + if member.hasExplicitValueReturn and #returns == 0 + report state, FindingCode.MissingReturn, member.line, member.name + +---Builds an ---@overload line describing a folded duplicate member's contract. +---@param state table +---@param cls MoonCatsClassIR +---@param member MoonCatsMemberIR +---@return string? overloadLine Nil when the member documents no callable contract. +buildDuplicateOverload = (state, cls, member) -> + return nil unless blockDocumentsFunction member.block + tags, returns = collectBlockTags member.block + parts = {} + -- fat members take the class or an instance as self + isFat = member.kind == MemberKind.Method or member.kind == MemberKind.StaticMethodFat or + member.kind == MemberKind.Constructor + table.insert parts, "self: #{cls.typeName}" if isFat + for tag in *tags + optional = tag.optional and "?" or "" + if tag.name == "..." + table.insert parts, "...: #{guardFunType tag.type}" + else + table.insert parts, "#{tag.name}#{optional}: #{guardFunType tag.type}" + guardedReturns = [guardFunType returnTag.type for returnTag in *returns] + returnsText = #guardedReturns > 0 and ": #{table.concat guardedReturns, ", "}" or "" + "---@overload fun(#{table.concat parts, ", "})#{returnsText}" + +---Groups a class's same-name members, choosing a primary declaration and folding documented +---secondaries into overload lines on it. +---@param state table +---@param cls MoonCatsClassIR +---@return MoonCatsMemberIR[] members The class's members with duplicates removed. +dedupeMembers = (state, cls) -> + groups = {} + for member in *cls.members + continue if member.kind == MemberKind.Metamethod + groups[member.name] or= {} + table.insert groups[member.name], member + + deduped = {} + for member in *cls.members + continue if member.kind == MemberKind.Metamethod + group = groups[member.name] + if #group == 1 + table.insert deduped, member + continue + -- pick the documented declaration, else the one with more parameters, else the earliest + primary = group[1] + for candidate in *group + continue if candidate == primary + better = if (candidate.block != nil) != (primary.block != nil) + candidate.block != nil + elseif #(candidate.params or {}) != #(primary.params or {}) + #(candidate.params or {}) > #(primary.params or {}) + else + candidate.line < primary.line + primary = candidate if better + if member == primary + primary.__overloads = {} + for candidate in *group + continue if candidate == primary or not candidate.block + overload = buildDuplicateOverload state, cls, candidate + if overload + table.insert primary.__overloads, overload + report state, FindingCode.DuplicateMemberFolded, candidate.line, candidate.name + table.insert deduped, primary + deduped + +---Emits a function member's annotation block and `function` statement, and records any contract findings. +---@param state table +---@param owner string +---@param member MoonCatsMemberIR +---@param sep string ":" for self-taking functions, "." for plain ones. +emitFunction = (state, owner, member, sep) -> + validateFunctionContract state, member + push state, "---@private" if member.isPrivate and not blockContainsPrivateTag member.block + pushBlock state, member.block + pushBlock state, member.__overloads + paramNames = [param.name for param in *member.params or {}] + push state, "function #{owner}#{sep}#{member.name}(#{table.concat paramNames, ", "}) end" + push state, "" + +---Emits a member that resolves to a function — a function literal, or a data member whose block +---documents a callable contract — as a `function` statement. +---@param state table +---@param owner string +---@param member MoonCatsMemberIR|MoonCatsFieldIR|MoonCatsAugmentationIR +---@param defaultSep string Separator to use when the block alone defines the contract. +emitFunctionValued = (state, owner, member, defaultSep) -> + isPrivate = member.isPrivate or member.name\match("^__") != nil + if member.valueInfo and member.valueInfo.kind == ValueKind.Function + sep = member.valueInfo.arrow == "fat" and ":" or "." + fnMember = { + kind: member.valueInfo.arrow == "fat" and MemberKind.StaticMethodFat or MemberKind.StaticMethodThin + name: member.name, line: member.line, block: member.block + params: member.params, hasExplicitValueReturn: member.hasExplicitValueReturn + :isPrivate + } + emitFunction state, owner, fnMember, sep + return + tags, _ = collectBlockTags member.block + push state, "---@private" if isPrivate and not blockContainsPrivateTag member.block + pushBlock state, member.block + paramNames = [tag.name for tag in *tags] + push state, "function #{owner}#{defaultSep}#{member.name}(#{table.concat paramNames, ", "}) end" + push state, "" + +---Emits the synthesized class describing an exported Enum's members, once per enum. +---@param state table +---@param enum MoonCatsEnumIR +emitEnumClass = (state, enum) -> + enumClassName = "#{enum.name}Enum" + return if state.synthesizedEnums[enumClassName] + state.synthesizedEnums[enumClassName] = true + memberType = state.packageSymbols.aliases[enum.name] and enum.name or nil + push state, "---@class #{enumClassName}: Enum" + for enumMember in *enum.members + fieldType = memberType or enumMember.literal + -- a bare negative literal is not a valid LuaCATS type; fall back to integer + fieldType = "integer" if not memberType and fieldType\sub(1, 1) == "-" + push state, "---@field #{enumMember.key} #{fieldType}" + push state, "" + if enum.computedKeyCount > 0 + report state, FindingCode.ComputedKeySkipped, enum.line, enum.name + +---Emits a class's annotation block and the `local` table its members attach to. +---@param state table +---@param cls MoonCatsClassIR +emitClassHeader = (state, cls) -> + ctor = nil + for member in *cls.members + ctor = member if member.kind == MemberKind.Constructor + cls.__fieldNames = collectFieldNames cls.block + + headerLines = {} + if cls.block + -- fold the constructor's prose into the class doc, ahead of the @class line + firstTagAt = nil + for i, line in ipairs cls.block + if line\match "^%-%-%-@" + firstTagAt = i + break + firstTagAt or= #cls.block + 1 + for i = 1, firstTagAt - 1 + table.insert headerLines, cls.block[i] + if ctor and ctor.block + for line in *ctor.block + table.insert headerLines, line if line\match("^%-%-%-[^@|]") and not line\match "^%-%-%-%s*$" + for i = firstTagAt, #cls.block + line = cls.block[i] + -- reattach a parent the annotation omits but the class statement declares + if cls.hasParent and not cls.annotatedParentName and line\match "^%-%-%-@class%s" + parentType = resolveRefType(cls.parentName, state, nil) or cls.parentName + line = "#{line}: #{parentType}" if parentType + table.insert headerLines, line + else + if ctor and ctor.block + for line in *ctor.block + table.insert headerLines, line if line\match("^%-%-%-[^@|]") and not line\match "^%-%-%-%s*$" + parentType = cls.hasParent and (resolveRefType(cls.parentName, state, nil) or cls.parentName) or nil + classLine = parentType and "---@class #{cls.typeName}: #{parentType}" or "---@class #{cls.typeName}" + table.insert headerLines, classLine + + ownAugmentations = [aug for aug in *state.ir.augmentations when aug.targetName == cls.name] + collectDataFields state, cls, cls.__dedupedMembers or cls.members, ownAugmentations, cls.__fieldNames, headerLines + table.insert headerLines, synthesizeOverload cls, ctor, state + + pushBlock state, headerLines + push state, "local #{cls.name} = {}" + push state, "" + + validateFunctionContract state, ctor if ctor + +---Emits one class member at its source position. +---@param state table +---@param owner string The local the member is emitted onto. +---@param member MoonCatsMemberIR +---@param cls MoonCatsClassIR +emitMember = (state, owner, member, cls) -> + switch member.kind + when MemberKind.Constructor + nil -- folded into the class header + when MemberKind.AccessorProperty + unless cls.__fieldNames and cls.__fieldNames[member.name] + report state, FindingCode.AccessorFieldMissing, member.line, member.name, cls.typeName + when MemberKind.EnumExport + -- the exporting @field lives on the class header; only the enum's class is emitted here + emitEnumClass state, member.enum + when MemberKind.Method, MemberKind.StaticMethodFat + emitFunction state, owner, member, ":" + when MemberKind.StaticMethodThin + emitFunction state, owner, member, "." + when MemberKind.InstanceDefault, MemberKind.StaticData + -- plain data became @field lines on the class header; callables emit here + return if cls.__fieldNames and cls.__fieldNames[member.name] + fnMember = resolveFunctionReference state, cls, member + if fnMember + emitFunction state, owner, fnMember, fnMember.arrow == "fat" and ":" or "." + elseif not isPlainData member + emitFunctionValued state, owner, member, "." + +---Emits a table module's annotation block and the `local` table its members attach to. +---@param state table +---@param varName string +emitTableModuleHeader = (state, varName) -> + exportIR = state.ir.export + headerLines = {"---@class #{state.ir.requireId}"} + fieldNames = {} + ownAugmentations = [aug for aug in *state.ir.augmentations when aug.targetName == exportIR.name or aug.targetName == varName] + collectDataFields state, nil, exportIR.fields or {}, ownAugmentations, fieldNames, headerLines + pushBlock state, headerLines + push state, "local #{varName} = {}" + push state, "" + +---Emits a module-level augmentation onto its owner — an enum re-export's synthesized class, or a +---function's statement. Plain-data augmentations are already declared on the owner's header, so +---they emit nothing here. +---@param state table +---@param owner string +---@param aug MoonCatsAugmentationIR +emitAugmentation = (state, owner, aug) -> + enum = augmentationEnum state, aug + if enum + emitEnumClass state, enum + return + fnRef = resolveFunctionReference state, nil, aug + if fnRef + emitFunction state, owner, fnRef, fnRef.arrow == "fat" and ":" or "." + elseif not isPlainData aug + emitFunctionValued state, owner, aug, "." + +---Emits a function module's exported function as a documented `local function` statement. +---@param state table +---@param exportIR MoonCatsExportIR +emitFunctionModule = (state, exportIR) -> + member = { + kind: MemberKind.StaticMethodThin + name: exportIR.name, line: 0 + block: exportIR.block, params: exportIR.params + hasExplicitValueReturn: exportIR.hasExplicitValueReturn + isPrivate: false + } + validateFunctionContract state, member + pushBlock state, member.block + paramNames = [param.name for param in *member.params or {}] + push state, "local function #{exportIR.name}(#{table.concat paramNames, ", "}) end" + push state, "" + +---Emits LuaLS .d.lua type definitions from parsed module IRs. +---@class MoonCatsEmitter +class MoonCatsEmitter + ---Emits the definition definition for one parsed module. + ---@param ir MoonCatsModuleIR The module IR produced by the parser. + ---@param packageSymbols? MoonCatsPackageSymbols Cross-module symbol context; omit for single-module runs. + ---@param diagnostics? MoonCatsDiagnostics Collection that receives findings discovered while emitting. + ---@return string definition The definition file's text. + emit: (ir, packageSymbols, diagnostics) => + state = { + :ir + packageSymbols: packageSymbols or {typeNameByRequireId: {}, aliases: {}} + :diagnostics + out: {} + synthesizedEnums: {} + } + -- module-local aliases count like run-wide ones for enum field typing + for name in pairs ir.aliases + state.packageSymbols.aliases[name] = true + + push state, "---@meta #{ir.requireId}" + push state, "" + + items = @@__collectItems state + table.sort items, (a, b) -> + return a.line < b.line if a.line != b.line + a.ordinal < b.ordinal + for item in *items + item.emit! + + returnName = @@__resolveReturnName state + push state, "return #{returnName}" if returnName + + text = table.concat state.out, "\n" + text ..= "\n" unless text\sub(-1) == "\n" + chunk, loadErr = (loadstring or load) text + unless chunk + report state, FindingCode.EmitFailure, nil, tostring loadErr + text + + ---Builds the line-ordered emission worklist: standalone segments, classes and their members, + ---table-module fields, and augmentations. + ---@private + ---@param state table + ---@return {line: integer, ordinal: integer, emit: fun()}[] items + @__collectItems = (state) => + {:ir} = state + items = {} + ordinal = 0 + add = (line, emit) -> + ordinal += 1 + table.insert items, {line: line or 0, :ordinal, :emit} + + for segment in *ir.segments + add segment.line, -> emitSegment state, segment + + for cls in *ir.classes + cls.__dedupedMembers = dedupeMembers state, cls + add cls.line, -> emitClassHeader state, cls + for member in *cls.__dedupedMembers + add member.line, -> emitMember state, cls.name, member, cls + + exportIR = ir.export + if exportIR.kind == ExportKind.Table + varName = exportIR.name and exportIR.name\match("^[%w_]+$") and exportIR.name or moduleVarName ir.requireId + state.tableVarName = varName + -- the module table must precede its fields; plain-data fields live on its header + add -1, -> emitTableModuleHeader state, varName + for field in *exportIR.fields or {} + fnRef = resolveFunctionReference state, nil, field + if fnRef + add field.line, -> emitFunction state, varName, fnRef, fnRef.arrow == "fat" and ":" or "." + elseif not isPlainData field + add field.line, -> emitFunctionValued state, varName, field, "." + elseif exportIR.kind == ExportKind.Function + add #ir.lines, -> emitFunctionModule state, exportIR + + for aug in *ir.augmentations + cls = ir.classesByName[aug.targetName] + owner = if cls + cls.name + elseif state.tableVarName and (aug.targetName == exportIR.name or aug.targetName == state.tableVarName) + state.tableVarName + if owner + add aug.line, -> emitAugmentation state, owner, aug + + items + + ---Answers the local name the module's trailing return should reference. + ---@private + ---@param state table + ---@return string? returnName Nil when the module's export is unknown. + @__resolveReturnName = (state) => + exportIR = state.ir.export + switch exportIR.kind + when ExportKind.Class + exportIR.class and exportIR.class.name or exportIR.name + when ExportKind.Table + state.tableVarName + when ExportKind.Function + exportIR.name + else + nil + +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +return UnitTestSuite\withTestExports MoonCatsEmitter, { + :inferLiteralType, :inferExpressionType, :moduleVarName, :synthesizeOverload + :dedupeMembers, :buildDuplicateOverload +} diff --git a/modules/l0/MoonCats/Parser.moon b/modules/l0/MoonCats/Parser.moon new file mode 100644 index 0000000..0072732 --- /dev/null +++ b/modules/l0/MoonCats/Parser.moon @@ -0,0 +1,1049 @@ +Common = require "l0.DependencyControl.Common" +Enum = require "l0.DependencyControl.Enum" +Diagnostics = require "l0.MoonCats.Diagnostics" +moonParse = require "moonscript.parse" +moonUtil = require "moonscript.util" + +FindingCode = Diagnostics.FindingCode + +---@alias MoonCatsMemberKind +---| "constructor" # Constructor: the class's `new` +---| "method" # Method: a fat-arrow instance method +---| "staticMethodFat" # StaticMethodFat: a class-level function called with the class as self +---| "staticMethodThin" # StaticMethodThin: a class-level plain function +---| "staticData" # StaticData: a class-level data field +---| "instanceDefault" # InstanceDefault: an instance-level default value declared in the class body +---| "accessorProperty" # AccessorProperty: a computed property declared via Accessors.property +---| "enumExport" # EnumExport: a class-level field holding a DependencyControl Enum +---| "metamethod" # Metamethod: a Lua metamethod +MemberKind = Enum "MoonCatsMemberKind", { + Constructor: "constructor" + Method: "method" + StaticMethodFat: "staticMethodFat" + StaticMethodThin: "staticMethodThin" + StaticData: "staticData" + InstanceDefault: "instanceDefault" + AccessorProperty: "accessorProperty" + EnumExport: "enumExport" + Metamethod: "metamethod" +} + +---@alias MoonCatsExportKind +---| "class" # Class: the module returns a class +---| "table" # Table: the module returns a plain table +---| "function" # Function: the module returns a bare function +---| "unknown" # Unknown: the module's export could not be determined +ExportKind = Enum "MoonCatsExportKind", { + Class: "class" + Table: "table" + Function: "function" + Unknown: "unknown" +} + +---@alias MoonCatsSymbolKind +---| "require" # Require: a local bound to require "..." +---| "import" # Import: a name destructured or imported from a required module +---| "class" # Class: a local bound to a class statement +---| "enum" # Enum: a local bound to a DependencyControl Enum definition +---| "function" # Function: a local bound to a function literal +---| "table" # Table: a local bound to a table literal +---| "literal" # Literal: a local bound to a scalar literal +---| "reference" # Reference: a local aliasing another local +---| "other" # Other: any binding the parser does not model +SymbolKind = Enum "MoonCatsSymbolKind", { + Require: "require" + Import: "import" + Class: "class" + Enum: "enum" + Function: "function" + Table: "table" + Literal: "literal" + Reference: "reference" + Other: "other" +} + +---@alias MoonCatsSegmentKind +---| "alias" # Alias: an @alias declaration with its variant lines +---| "class" # Class: an annotation-only @class declaration with its @field lines +---| "rest" # Rest: the member contract part of a block (params, returns, prose) +SegmentKind = Enum "MoonCatsSegmentKind", { + Alias: "alias" + Class: "class" + Rest: "rest" +} + +---@alias MoonCatsValueKind +---| "literal" # Literal: a scalar literal with a reconstructible source token +---| "reference" # Reference: a bare identifier +---| "call" # Call: a call chain (constructor or function invocation) +---| "table" # Table: a table literal +---| "function" # Function: a function literal +---| "expression" # Expression: an operator expression +---| "other" # Other: anything else +ValueKind = Enum "MoonCatsValueKind", { + Literal: "literal" + Reference: "reference" + Call: "call" + Table: "table" + Function: "function" + Expression: "expression" + Other: "other" +} + +metamethodNames = Common.makeSet { + "__tostring", "__eq", "__lt", "__le", "__call", "__index", "__newindex", + "__pairs", "__ipairs", "__gc", "__len", "__concat", "__add", "__sub", + "__mul", "__div", "__mod", "__pow", "__unm", "__metatable", "__mode", "__close" +} + +---A run of contiguous doc-comment lines. +---@class MoonCatsAnnotationBlock +---@field startLine integer 1-based line of the block's first doc line. +---@field endLine integer 1-based line of the block's last doc line. +---@field lines string[] The doc lines, verbatim from the leading `---` on. + +---A slice of an annotation block — a standalone alias/class declaration or the member contract. +---@class MoonCatsSegment +---@field kind MoonCatsSegmentKind +---@field name? string Declared alias/class name; nil for rest segments. +---@field lines string[] The segment's doc lines, verbatim. +---@field line? integer Source line the segment is anchored to, for output ordering. + +---One parameter of a function signature. +---@class MoonCatsParam +---@field name string Parameter name with any `@`/`@@` prefix stripped. +---@field hasDefault boolean Whether the signature declares a default value. +---@field isVararg boolean Whether this is the `...` parameter. + +---Classification of a member's or field's value expression. +---@class MoonCatsValueInfo +---@field kind MoonCatsValueKind +---@field token? string Reconstructed source token for literal values. +---@field literalKind? string Lua type name of a literal value ("number", "string", "boolean", "nil"). +---@field refName? string Identifier name for reference values. +---@field baseName? string Chain base identifier for call values. +---@field params? MoonCatsParam[] Signature for function values. +---@field arrow? string "fat" or "slim" for function values. +---@field node? table The raw AST node for table/call/expression/other values. + +---A class-body member. +---@class MoonCatsMemberIR +---@field kind MoonCatsMemberKind +---@field name string +---@field line integer Source line of the member's definition. +---@field block? string[] The member's bound annotation lines (rest segment), verbatim. +---@field params? MoonCatsParam[] Signature for function members. +---@field arrow? string "fat" or "slim" for function members. +---@field hasExplicitValueReturn? boolean Whether the body contains an explicit valued return. +---@field isPrivate boolean From a `__` name prefix or an @private tag in the block. +---@field valueInfo? MoonCatsValueInfo For data members. +---@field enum? MoonCatsEnumIR For enum-export members. + +---A DependencyControl Enum definition found in the module. +---@class MoonCatsEnumIR +---@field name string The Enum's declared runtime name. +---@field line integer +---@field members {key: string, literal: string}[] Literal-valued members in definition order. +---@field computedKeyCount integer Number of members skipped for non-literal keys or values. +---@field exportedAs? string Member/field name the Enum is exported under, when it is. + +---A real class found in the module. +---@class MoonCatsClassIR +---@field name string The moon class statement's name. +---@field line integer +---@field hasParent boolean +---@field parentName? string The extends expression's identifier when it is a simple reference. +---@field block? string[] The class's bound annotation block, verbatim (prose + @class + @field lines). +---@field typeName string LuaCATS type name: the block's @class name when present, else the moon name. +---@field annotatedParentName? string Parent named in the block's @class line, when present. +---@field members MoonCatsMemberIR[] In source order. +---@field localSymbols table<string, MoonCatsSymbol> Class-body locals. + +---A module-level dotted assignment augmenting a class or module table. +---@class MoonCatsAugmentationIR +---@field targetName string Identifier being augmented. +---@field name string Field name assigned. +---@field line integer +---@field block? string[] +---@field valueInfo MoonCatsValueInfo +---@field params? MoonCatsParam[] Signature when the value is a function literal. +---@field arrow? string +---@field hasExplicitValueReturn? boolean + +---A field of a table-module export. +---@class MoonCatsFieldIR +---@field name string +---@field line integer +---@field block? string[] +---@field valueInfo MoonCatsValueInfo +---@field params? MoonCatsParam[] Signature when the value is a function literal. +---@field arrow? string +---@field hasExplicitValueReturn? boolean + +---What a module returns to requirers. +---@class MoonCatsExportIR +---@field kind MoonCatsExportKind +---@field name? string The exported local/class name, when the export is a named value. +---@field class? MoonCatsClassIR For class exports. +---@field fields? MoonCatsFieldIR[] For table exports. +---@field params? MoonCatsParam[] For function exports. +---@field block? string[] For function exports: the function's bound annotation lines. +---@field hasExplicitValueReturn? boolean For function exports. + +---A resolved module-scope or class-scope name binding. +---@class MoonCatsSymbol +---@field kind MoonCatsSymbolKind +---@field requireId? string For require/import symbols. +---@field enum? MoonCatsEnumIR For enum symbols. +---@field params? MoonCatsParam[] For function symbols. +---@field arrow? string For function symbols. +---@field hasExplicitValueReturn? boolean For function symbols. +---@field node? table Table node for table symbols. +---@field target? string For reference symbols. +---@field token? string For literal symbols. +---@field literalKind? string For literal symbols. +---@field block? string[] Annotation lines bound to the symbol's definition. +---@field class? MoonCatsClassIR For class symbols. + +---Everything the emitter needs to know about one parsed module. +---@class MoonCatsModuleIR +---@field requireId string +---@field lines string[] The module's source lines. +---@field segments MoonCatsSegment[] Standalone alias/class declarations, in source order. +---@field classes MoonCatsClassIR[] Real classes, in source order. +---@field classesByName table<string, MoonCatsClassIR> +---@field enums MoonCatsEnumIR[] All Enum definitions found. +---@field aliases table<string, boolean> Names of @alias declarations found in this module. +---@field symbols table<string, MoonCatsSymbol> Module-scope name bindings. +---@field export MoonCatsExportIR +---@field augmentations MoonCatsAugmentationIR[] Module-level dotted assignments. + +---Returns a node's AST type tag, or "value" for scalars. +---@param node any +---@return string tag +nodeType = (node) -> + return "value" unless "table" == type node + node[1] + +---Splits source text into lines with trailing carriage returns stripped. +---@param source string +---@return string[] lines +splitLines = (source) -> + lines = {} + pos = 1 + while true + nl = source\find "\n", pos, true + unless nl + last = source\sub(pos)\gsub "\r$", "" + table.insert lines, last + break + line = source\sub(pos, nl - 1)\gsub "\r$", "" + table.insert lines, line + pos = nl + 1 + lines + +---Returns the smallest source position attached to a node or any of its descendants. +---@param node any +---@return integer? pos Nil when the node is not a table or carries no positions. +minDescendantPos = (node) -> + return nil unless "table" == type node + best = node[-1] + for _, child in pairs node + if "table" == type child + pos = minDescendantPos child + best = pos if pos and (not best or pos < best) + best + +---Returns the 1-based source line a node is defined on, falling back to its earliest descendant. +---@param node any +---@param source string +---@return integer? line Nil when no position can be resolved from the node. +nodeLine = (node, source) -> + return nil unless "table" == type node + pos = node[-1] or minDescendantPos node + pos and moonUtil.pos_to_line source, pos + +---Marks every line whose content lies inside a string literal, so the comment scanner skips them. +---@param tree table Parsed AST root. +---@param source string +---@return table<integer, boolean> mask +computeStringMask = (tree, source) -> + mask = {} + + maskString = (node) -> + startPos = node[-1] + return unless startPos + startLine = moonUtil.pos_to_line source, startPos + delim = node[2] + endLine = startLine + if "string" == type(delim) and delim\sub(1, 1) == "[" + level = #(delim\match("^%[(=*)%[") or "") + closer = "]" .. ("=")\rep(level) .. "]" + closePos = source\find closer, startPos + #delim, true + endLine = moonUtil.pos_to_line source, closePos if closePos + else + newlines = 0 + for i = 3, #node + chunk = node[i] + if "string" == type chunk + newlines += select 2, chunk\gsub("\n", "") + endLine = startLine + newlines + for l = startLine, endLine + mask[l] = true + + walk = (node) -> + return unless "table" == type node + maskString node if node[1] == "string" + for _, child in pairs node + walk child if "table" == type child + + walk tree + mask + +---Collects maximal runs of doc-comment lines outside string literals. +---A plain -- line (or a 4+-dash divider) terminates a block and is never part of one. +---@param lines string[] +---@param mask table<integer, boolean> String-interior lines to skip. +---@return MoonCatsAnnotationBlock[] blocks In source order. +scanCommentBlocks = (lines, mask) -> + blocks = {} + current = nil + for i, line in ipairs lines + docText = not mask[i] and line\match "^%s*(%-%-%-.*)$" + docText = nil if docText and docText\match "^%-%-%-%-" + if docText + current or= {startLine: i, lines: {}} + table.insert current.lines, docText + current.endLine = i + elseif current + table.insert blocks, current + current = nil + table.insert blocks, current if current + blocks + +---Splits a block into standalone @alias/@class segments and the remaining member contract. +---Prose directly above an @alias/@class line belongs to that segment; prose after variant +---lines falls through to the next segment, so a member doc following an alias stays with the member. +---@param block MoonCatsAnnotationBlock +---@return MoonCatsSegment[] segments In block order, with at most one rest segment. +segmentBlock = (block) -> + segments = {} + pendingProse = {} + current, rest = nil, nil + lastContext = nil + + flushCurrent = -> + if current + table.insert segments, current + current = nil + + appendRest = (line) -> + unless rest + rest = {kind: SegmentKind.Rest, lines: {}} + table.insert segments, rest + for prose in *pendingProse + table.insert rest.lines, prose + pendingProse = {} + table.insert rest.lines, line if line + + for line in *block.lines + tag = line\match "^%-%-%-@(%w+)" + isVariant = not tag and line\match("^%-%-%-|") != nil + if tag == "alias" or tag == "class" + flushCurrent! + name = line\match("^%-%-%-@%w+%s+%(%w+%)%s+([%w_%.]+)") or line\match "^%-%-%-@%w+%s+([%w_%.]+)" + current = {kind: tag == "alias" and SegmentKind.Alias or SegmentKind.Class, :name, lines: {}} + for prose in *pendingProse + table.insert current.lines, prose + pendingProse = {} + table.insert current.lines, line + lastContext = tag + elseif isVariant + if current and current.kind == SegmentKind.Alias + table.insert current.lines, line + else + appendRest line + lastContext = "variant" + elseif tag == "field" or tag == "operator" + if current and current.kind == SegmentKind.Class + table.insert current.lines, line + else + appendRest line + lastContext = tag + elseif tag + flushCurrent! + appendRest line + lastContext = "rest" + else + -- prose with no tag continues the preceding tag line, else it leads the next declaration + if current and (lastContext == "alias" or lastContext == "class" or lastContext == "field" or lastContext == "operator") + table.insert current.lines, line + elseif rest and lastContext == "rest" + table.insert rest.lines, line + else + table.insert pendingProse, line + flushCurrent! + appendRest nil if #pendingProse > 0 + segments + +---Extracts the declared type name and parent from a class annotation block. +---@param blockLines string[] +---@return string? name The @class type name, or nil when the block has no @class line. +---@return string? parent The parent type from a `Name: Parent` line, or nil when unparented. +classAnnotationInfo = (blockLines) -> + for line in *blockLines + name, parent = line\match "^%-%-%-@class%s+([%w_%.]+)%s*:%s*([%w_%.]+)" + name or= line\match "^%-%-%-@class%s+([%w_%.]+)" + return name, parent if name + nil + +---Reports whether an annotation block contains an @private tag. +---@param blockLines? string[] +---@return boolean private +blockContainsPrivate = (blockLines) -> + return false unless blockLines + for line in *blockLines + return true if line\match "^%-%-%-@private" + false + +---Converts an fndef's argument list into parameter records. +---@param args table The fndef node's argument tuples. +---@return MoonCatsParam[] params Caller-facing parameters; the leading underscores of an +---`@field`/`@@field` argument (a field-visibility marker on the field, not the argument) are dropped. +paramsFromFndef = (args) -> + return {} unless "table" == type args + params = {} + for tuple in *args + nameNode = tuple[1] + param = if "table" == type nameNode + {name: (nameNode[2]\gsub "^_+", ""), hasDefault: tuple[2] != nil, isVararg: false} + elseif nameNode == "..." + {name: "...", hasDefault: false, isVararg: true} + else + {name: nameNode, hasDefault: tuple[2] != nil, isVararg: false} + table.insert params, param + params + +---Reconstructs the source token of a scalar literal node. +---@param node any +---@return string? token Reconstructed literal, or nil when the node is not a scalar literal. +---@return string? literalKind Lua type name of the literal, nil in the same non-literal case. +literalTokenFromNode = (node) -> + switch nodeType node + when "number" + node[2], "number" + when "string" + -- only plain single-chunk strings reconstruct; interpolated ones don't + if #node == 3 and "string" == type node[3] + "%q"\format(node[3]), "string" + when "ref" + switch node[2] + when "true" then "true", "boolean" + when "false" then "false", "boolean" + when "nil" then "nil", "nil" + when "minus" + token, kind = literalTokenFromNode node[2] + if token and kind == "number" + "-" .. token, "number" + +---Returns the content of a plain, non-interpolated string node. +---@param node any +---@return string? content Nil when the node is not a plain single-chunk string. +plainStringFromNode = (node) -> + return nil unless nodeType(node) == "string" + return nil unless #node == 3 and "string" == type node[3] + node[3] + +---Extracts the require identifier from a require "..." chain. +---@param node any +---@return string? requireId Nil when the chain is not a `require "..."` call. +requireIdFromChain = (node) -> + return nil unless nodeType(node) == "chain" + base = node[2] + return nil unless nodeType(base) == "ref" and base[2] == "require" + callItem = node[3] + return nil unless "table" == type(callItem) and callItem[1] == "call" and node[4] == nil + plainStringFromNode callItem[2][1] + +---Reduces a chain node to a flat summary for pattern-matching. +---@param node any +---@return {baseName?: string, accessor?: string, accessorKind?: string, callArgs?: table}? info Base identifier, last dot/colon accessor, and last call arguments; nil when the node is not a chain. +describeChain = (node) -> + return nil unless nodeType(node) == "chain" + base = node[2] + baseName = nodeType(base) == "ref" and base[2] or nil + accessor, accessorKind, callArgs = nil, nil, nil + for i = 3, #node + item = node[i] + continue unless "table" == type item + switch item[1] + when "dot", "colon" + accessor, accessorKind = item[2], item[1] + when "call" + callArgs = item[2] + {:baseName, :accessor, :accessorKind, :callArgs} + +---Reports whether an fndef body contains an explicit return with values, without descending +---into nested function literals. +---@param fndefNode table +---@return boolean returns +fndefHasValueReturn = (fndefNode) -> + found = false + search = (node) -> + return if found or "table" != type node + return if node[1] == "fndef" + if node[1] == "return" + explist = node[2] + found = true if "table" == type(explist) and explist[2] != nil + return + for _, child in pairs node + search child if "table" == type child + body = fndefNode[5] + return false unless "table" == type body + for stm in *body + search stm + found + +---Recognizes an `Enum "Name", {...}` definition chain. +---@param node any +---@param resolveSymbol fun(name: string): MoonCatsSymbol? +---@return {name: string, members: table[], computedKeyCount: integer}? spec Nil when the chain is not an Enum definition. +enumSpecFromChain = (node, resolveSymbol) -> + info = describeChain node + return nil unless info and info.baseName and not info.accessor and info.callArgs + sym = resolveSymbol info.baseName + return nil unless sym and sym.kind == SymbolKind.Require and sym.requireId\match "%.Enum$" + name = plainStringFromNode info.callArgs[1] + tableNode = info.callArgs[2] + return nil unless name and nodeType(tableNode) == "table" + members, computedKeyCount = {}, 0 + for pair in *tableNode[2] + key, value = pair[1], pair[2] + token = value != nil and literalTokenFromNode value + if nodeType(key) == "key_literal" and token + table.insert members, {key: key[2], literal: token} + else + computedKeyCount += 1 + {:name, :members, :computedKeyCount} + +---Recognizes an `Accessors.property {...}` computed-property value. +---@param node any +---@param resolveSymbol fun(name: string): MoonCatsSymbol? +---@return boolean isProperty +isAccessorsProperty = (node, resolveSymbol) -> + info = describeChain node + return false unless info and info.baseName and info.accessor == "property" and info.accessorKind == "dot" + sym = resolveSymbol info.baseName + (sym and sym.kind == SymbolKind.Require and sym.requireId\match "%.Accessors$") != nil + +---Classifies a member's or field's value expression. +---@param value any +---@return MoonCatsValueInfo info +valueInfoFromNode = (value) -> + return {kind: ValueKind.Other} unless "table" == type value + token, literalKind = literalTokenFromNode value + return {kind: ValueKind.Literal, :token, :literalKind} if token + switch nodeType value + when "ref" + {kind: ValueKind.Reference, refName: value[2]} + when "table" + {kind: ValueKind.Table, node: value} + when "chain" + -- only an actual invocation types as its base (a constructor/factory result); + -- a bare dotted access is a field alias whose type the base name doesn't carry + info = describeChain value + hasCall = info and info.callArgs != nil + {kind: ValueKind.Call, baseName: hasCall and info.baseName or nil, node: value} + when "fndef" + {kind: ValueKind.Function, params: paramsFromFndef(value[2]), arrow: value[4], node: value} + when "exp" + {kind: ValueKind.Expression, node: value} + else + {kind: ValueKind.Other, node: value} + +---Records a finding when a diagnostics collection is attached to the parse. +---@param ctx table +---@param code MoonCatsFindingCode +---@param line? integer +---@param ... any Message format values. +report = (ctx, code, line, ...) -> + ctx.diagnostics\add code, ctx.requireId, line, ... if ctx.diagnostics + +---Registers the item a given source line defines; the first item on a line wins. +---@param ctx table +---@param line? integer +---@param item table +registerLineItem = (ctx, line, item) -> + return unless line + ctx.lineItems[line] or= item + +---Creates an EnumIR from a recognized Enum definition and registers it. +---@param ctx table +---@param spec table The recognized definition (name, members, computedKeyCount). +---@param line integer +---@return MoonCatsEnumIR enum +recordEnum = (ctx, spec, line) -> + enum = {name: spec.name, members: spec.members, computedKeyCount: spec.computedKeyCount, :line} + table.insert ctx.enums, enum + enum + +---Builds a symbol record from a local's value expression, registering any Enum definition found. +---@param value any +---@param ctx table +---@param line? integer +---@return MoonCatsSymbol symbol +symbolFromValue = (value, ctx, line) -> + return {kind: SymbolKind.Other} unless "table" == type value + token, literalKind = literalTokenFromNode value + return {kind: SymbolKind.Literal, :token, :literalKind} if token + requireId = requireIdFromChain value + return {kind: SymbolKind.Require, :requireId} if requireId + spec = enumSpecFromChain value, (name) -> ctx.symbols[name] + if spec + return {kind: SymbolKind.Enum, enum: recordEnum ctx, spec, line or 0} + switch nodeType value + when "fndef" + { + kind: SymbolKind.Function + params: paramsFromFndef value[2] + arrow: value[4] + hasExplicitValueReturn: fndefHasValueReturn value + } + when "table" + {kind: SymbolKind.Table, node: value} + when "ref" + {kind: SymbolKind.Reference, target: value[2]} + when "chain" + -- setmetatable({...}, ...) modules expose the table argument's fields + info = describeChain value + if info and info.baseName == "setmetatable" and info.callArgs and nodeType(info.callArgs[1]) == "table" + {kind: SymbolKind.Table, node: info.callArgs[1]} + else + {kind: SymbolKind.Other} + else + {kind: SymbolKind.Other} + +---Classifies one class-body member declared as a props key/value pair. +---@param name string +---@param keyType string "key_literal", "self", or "self_class". +---@param value any +---@param line integer +---@param resolveSymbol fun(name: string): MoonCatsSymbol? +---@param ctx table +---@return MoonCatsMemberIR member +classifyPropsMember = (name, keyType, value, line, resolveSymbol, ctx) -> + member = {:name, :line, isPrivate: name\match("^__") != nil} + if nodeType(value) == "fndef" + member.params = paramsFromFndef value[2] + member.arrow = value[4] + member.hasExplicitValueReturn = fndefHasValueReturn value + member.kind = if name == "new" + MemberKind.Constructor + elseif metamethodNames[name] + member.isPrivate = false + MemberKind.Metamethod + elseif member.arrow == "slim" + MemberKind.StaticMethodThin + elseif keyType == "self" or keyType == "self_class" + MemberKind.StaticMethodFat + else + MemberKind.Method + return member + if isAccessorsProperty value, resolveSymbol + member.kind = MemberKind.AccessorProperty + return member + spec = enumSpecFromChain value, resolveSymbol + if spec + enum = recordEnum ctx, spec, line + enum.exportedAs = name + member.kind = MemberKind.EnumExport + member.enum = enum + return member + member.valueInfo = valueInfoFromNode value + if member.valueInfo.kind == ValueKind.Reference + sym = resolveSymbol member.valueInfo.refName + if sym and sym.kind == SymbolKind.Enum + sym.enum.exportedAs or= name + member.kind = MemberKind.EnumExport + member.enum = sym.enum + return member + member.kind = keyType == "key_literal" and MemberKind.InstanceDefault or MemberKind.StaticData + member + +---Classifies one class-body static declared as an assignment to @name. +---@param name string +---@param value any +---@param line integer +---@param resolveSymbol fun(name: string): MoonCatsSymbol? +---@param ctx table +---@return MoonCatsMemberIR member +classifyStaticMember = (name, value, line, resolveSymbol, ctx) -> + member = {:name, :line, isPrivate: name\match("^__") != nil} + if nodeType(value) == "fndef" + member.params = paramsFromFndef value[2] + member.arrow = value[4] + member.hasExplicitValueReturn = fndefHasValueReturn value + member.kind = member.arrow == "slim" and MemberKind.StaticMethodThin or MemberKind.StaticMethodFat + return member + spec = enumSpecFromChain value, resolveSymbol + if spec + enum = recordEnum ctx, spec, line + enum.exportedAs = name + member.kind = MemberKind.EnumExport + member.enum = enum + return member + member.valueInfo = valueInfoFromNode value + if member.valueInfo.kind == ValueKind.Reference + sym = resolveSymbol member.valueInfo.refName + if sym and sym.kind == SymbolKind.Enum + sym.enum.exportedAs or= name + member.kind = MemberKind.EnumExport + member.enum = sym.enum + return member + member.kind = MemberKind.StaticData + member + +---Walks a class statement's body into a ClassIR, registering members and class locals. +---@param classNode table +---@param ctx table +---@return MoonCatsClassIR class +walkClass = (classNode, ctx) -> + cls = { + name: classNode[2] + line: nodeLine(classNode, ctx.source) or 0 + hasParent: "table" == type classNode[3] + members: {} + localSymbols: {} + } + cls.typeName = cls.name + if cls.hasParent and nodeType(classNode[3]) == "ref" + cls.parentName = classNode[3][2] + resolveSymbol = (name) -> cls.localSymbols[name] or ctx.symbols[name] + + body = classNode[4] + if "table" == type body + for item in *body + continue unless "table" == type item + if item[1] == "props" + for j = 2, #item + kv = item[j] + key, value = kv[1], kv[2] + keyType = nodeType key + if keyType == "key_literal" or keyType == "self" or keyType == "self_class" + line = nodeLine(value, ctx.source) or cls.line + member = classifyPropsMember key[2], keyType, value, line, resolveSymbol, ctx + table.insert cls.members, member + registerLineItem ctx, line, {kind: "member", :member} + else + report ctx, FindingCode.ComputedKeySkipped, nodeLine(kv, ctx.source), cls.name + else + stm = item[2] + continue unless "table" == type stm + continue unless nodeType(stm) == "assign" + names, values = stm[2], stm[3] + continue unless "table" == type(names) and "table" == type values + line = nodeLine(stm, ctx.source) or cls.line + first = names[1] + firstType = nodeType first + if firstType == "self" or firstType == "self_class" + member = classifyStaticMember first[2], values[1], line, resolveSymbol, ctx + table.insert cls.members, member + registerLineItem ctx, line, {kind: "member", :member} + elseif firstType == "ref" + for i, nameNode in ipairs names + continue unless nodeType(nameNode) == "ref" + cls.localSymbols[nameNode[2]] = symbolFromValue values[i], ctx, line + registerLineItem ctx, line, {kind: "local", name: first[2], scope: cls.localSymbols} + + table.insert ctx.classes, cls + ctx.classesByName[cls.name] = cls + ctx.symbols[cls.name] = {kind: SymbolKind.Class, class: cls} + registerLineItem ctx, cls.line, {kind: "class", class: cls} + cls + +---Handles one module-level assignment — a local binding, a destructuring import, or a dotted augmentation. +---@param stm table +---@param ctx table +walkModuleAssign = (stm, ctx) -> + names, values = stm[2], stm[3] + return unless "table" == type(names) and "table" == type values + line = nodeLine stm, ctx.source + first = names[1] + firstType = nodeType first + + if #names == 1 and firstType == "chain" + info = describeChain first + if info and info.baseName and info.accessor and info.accessorKind == "dot" and not info.callArgs + value = values[1] + aug = { + targetName: info.baseName + name: info.accessor + line: line or 0 + valueInfo: valueInfoFromNode value + } + if aug.valueInfo.kind == ValueKind.Function + aug.params, aug.arrow = aug.valueInfo.params, aug.valueInfo.arrow + aug.hasExplicitValueReturn = fndefHasValueReturn value + table.insert ctx.augmentations, aug + registerLineItem ctx, aug.line, {kind: "augmentation", augmentation: aug} + return + + if #names == 1 and firstType == "table" + requireId = requireIdFromChain values[1] + for pair in *first[2] + target = pair[2] + if "table" == type(target) and nodeType(target) == "ref" + ctx.symbols[target[2]] = requireId and {kind: SymbolKind.Import, :requireId} or {kind: SymbolKind.Other} + registerLineItem ctx, line, {kind: "skip"} + return + + for i, nameNode in ipairs names + continue unless nodeType(nameNode) == "ref" + ctx.symbols[nameNode[2]] = symbolFromValue values[i], ctx, line + if firstType == "ref" + registerLineItem ctx, line, {kind: "local", name: first[2], scope: ctx.symbols} + +---Handles an import statement, binding each imported name. +---@param stm table +---@param ctx table +walkImport = (stm, ctx) -> + names, source = stm[2], stm[3] + requireId = requireIdFromChain source + if "table" == type names + for nameNode in *names + name = if "table" == type nameNode then nameNode[2] else nameNode + continue unless "string" == type name + ctx.symbols[name] = requireId and {kind: SymbolKind.Import, :requireId} or {kind: SymbolKind.Other} + registerLineItem ctx, nodeLine(stm, ctx.source), {kind: "skip"} + +---Resolves an export expression to its exported value. +---@param node any +---@param ctx table +---@return MoonCatsExportIR export +resolveExportTarget = (node, ctx) -> + switch nodeType node + when "ref" + name = node[2] + sym = ctx.symbols[name] + if sym + switch sym.kind + when SymbolKind.Class + {kind: ExportKind.Class, :name, class: sym.class} + when SymbolKind.Function + { + kind: ExportKind.Function, :name + params: sym.params, block: sym.block + hasExplicitValueReturn: sym.hasExplicitValueReturn + } + when SymbolKind.Table + {kind: ExportKind.Table, :name, tableNode: sym.node} + else + {kind: ExportKind.Unknown, :name} + else + {kind: ExportKind.Unknown, :name} + when "table" + {kind: ExportKind.Table, tableNode: node} + when "chain" + info = describeChain node + if info and info.callArgs and info.callArgs[1] and + (info.accessor == "withTestExports" or info.accessor == "register" or info.accessor == "install") + resolveExportTarget info.callArgs[1], ctx + else + {kind: ExportKind.Unknown} + else + {kind: ExportKind.Unknown} + +---Determines what the module returns, from its last top-level statement. +---@param tree table +---@param ctx table +---@return MoonCatsExportIR export +resolveModuleExport = (tree, ctx) -> + last = tree[#tree] + return {kind: ExportKind.Unknown} unless "table" == type last + switch nodeType last + when "return" + explist = last[2] + if "table" == type(explist) and explist[2] != nil + resolveExportTarget explist[2], ctx + else + {kind: ExportKind.Unknown} + when "class" + cls = ctx.classesByName[last[2]] + {kind: ExportKind.Class, name: last[2], class: cls} + when "chain" + info = describeChain last + if info and info.accessor == "install" and info.callArgs and info.callArgs[1] + resolveExportTarget info.callArgs[1], ctx + else + {kind: ExportKind.Unknown} + when "table" + {kind: ExportKind.Table, tableNode: last} + else + {kind: ExportKind.Unknown} + +---Builds field records for a table-module export and registers them for block binding. +---@param ctx table +buildExportFields = (ctx) -> + exportIR = ctx.export + return unless exportIR.kind == ExportKind.Table and exportIR.tableNode + exportIR.fields = {} + for pair in *exportIR.tableNode[2] + key, value = pair[1], pair[2] + continue if value == nil + unless nodeType(key) == "key_literal" + report ctx, FindingCode.ComputedKeySkipped, nodeLine(key, ctx.source), ctx.requireId + continue + line = nodeLine(value, ctx.source) or 0 + field = {name: key[2], :line, valueInfo: valueInfoFromNode value} + if field.valueInfo.kind == ValueKind.Function + field.params, field.arrow = field.valueInfo.params, field.valueInfo.arrow + field.hasExplicitValueReturn = fndefHasValueReturn value + table.insert exportIR.fields, field + registerLineItem ctx, line, {kind: "field", :field} + +---Hoists a block's standalone alias/class segments into the module IR and returns the member contract. +---@param segments MoonCatsSegment[] +---@param ctx table +---@param line integer Source line the block starts on, for output ordering. +---@return string[]? restLines The rest segment's lines, or nil when the block has none. +hoistSegments = (segments, ctx, line) -> + restLines = nil + for segment in *segments + if segment.kind == SegmentKind.Rest + restLines = segment.lines + else + segment.line = line + table.insert ctx.segments, segment + if segment.kind == SegmentKind.Alias and segment.name + ctx.aliases[segment.name] = true + restLines + +---Binds every comment block to the item defined on the following line, hoisting standalone declarations. +---@param blocks MoonCatsAnnotationBlock[] +---@param ctx table +bindBlocks = (blocks, ctx) -> + for block in *blocks + target = ctx.lineItems[block.endLine + 1] + unless target + hoistSegments (segmentBlock block), ctx, block.startLine + continue + switch target.kind + when "class" + -- a real class keeps its whole block verbatim: prose, @class line, and @field list + target.class.block = block.lines + when "member" + rest = hoistSegments (segmentBlock block), ctx, block.startLine + target.member.block = rest if rest + when "augmentation" + rest = hoistSegments (segmentBlock block), ctx, block.startLine + target.augmentation.block = rest if rest + when "field" + rest = hoistSegments (segmentBlock block), ctx, block.startLine + target.field.block = rest if rest + when "local" + rest = hoistSegments (segmentBlock block), ctx, block.startLine + if rest and target.name + sym = target.scope[target.name] + sym.block = rest if sym + else + hoistSegments (segmentBlock block), ctx, block.startLine + +---Resolves each class's annotated type name and marks its @private members, once blocks are bound. +---@param ctx table +finalizeClasses = (ctx) -> + for cls in *ctx.classes + if cls.block + annotatedName, annotatedParent = classAnnotationInfo cls.block + if annotatedName + if annotatedName != cls.name + report ctx, FindingCode.ClassNameMismatch, cls.line, annotatedName, cls.name + cls.typeName = annotatedName + cls.annotatedParentName = annotatedParent + for member in *cls.members + member.isPrivate = true if blockContainsPrivate member.block + +---Parses annotated MoonScript sources into the intermediate representation consumed by the definition emitter. +---@class MoonCatsParser +class MoonCatsParser + ---@type Enum + @MemberKind = MemberKind + ---@type Enum + @ExportKind = ExportKind + ---@type Enum + @SymbolKind = SymbolKind + ---@type Enum + @SegmentKind = SegmentKind + ---@type Enum + @ValueKind = ValueKind + + ---Parses one MoonScript module into its definition-extraction IR. + ---@param source string The module's MoonScript source text. + ---@param requireId string Require identifier of the module (e.g. "l0.DependencyControl.Timer"). + ---@param diagnostics? MoonCatsDiagnostics Collection that receives findings discovered while parsing. + ---@return MoonCatsModuleIR? ir The parsed module IR, or nil when the source fails to parse. + ---@return string? err Parse error message when parsing failed. + parse: (source, requireId, diagnostics) => + tree, err = moonParse.string source + return nil, tostring err unless tree + + ctx = { + :source, :requireId, :diagnostics + lines: splitLines source + symbols: {} + enums: {} + classes: {} + classesByName: {} + segments: {} + aliases: {} + augmentations: {} + lineItems: {} + } + + for stm in *tree + continue unless "table" == type stm + switch nodeType stm + when "assign" then walkModuleAssign stm, ctx + when "class" then walkClass stm, ctx + when "import" then walkImport stm, ctx + + ctx.export = resolveModuleExport tree, ctx + if ctx.export.kind == ExportKind.Unknown + report ctx, FindingCode.NoExport, #ctx.lines + buildExportFields ctx + + mask = computeStringMask tree, source + blocks = scanCommentBlocks ctx.lines, mask + bindBlocks blocks, ctx + finalizeClasses ctx + + -- binding runs after export resolution, so late-fill blocks copied off symbols + if ctx.export.kind == ExportKind.Function and ctx.export.name + sym = ctx.symbols[ctx.export.name] + ctx.export.block or= sym and sym.block + + -- enum exports through module-level augmentations (Class.Name = enumLocal) + for aug in *ctx.augmentations + if aug.valueInfo.kind == ValueKind.Reference + sym = ctx.symbols[aug.valueInfo.refName] + if sym and sym.kind == SymbolKind.Enum + sym.enum.exportedAs or= aug.name + + { + requireId: ctx.requireId + lines: ctx.lines + segments: ctx.segments + classes: ctx.classes + classesByName: ctx.classesByName + enums: ctx.enums + aliases: ctx.aliases + symbols: ctx.symbols + export: ctx.export + augmentations: ctx.augmentations + } + +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +return UnitTestSuite\withTestExports MoonCatsParser, { + :splitLines, :computeStringMask, :scanCommentBlocks, :segmentBlock + :paramsFromFndef, :literalTokenFromNode, :requireIdFromChain, :describeChain + :fndefHasValueReturn, :classAnnotationInfo +} diff --git a/modules/l0/MoonCats/annotations.moon b/modules/l0/MoonCats/annotations.moon new file mode 100644 index 0000000..26eac48 --- /dev/null +++ b/modules/l0/MoonCats/annotations.moon @@ -0,0 +1,268 @@ +-- Shared LuaCATS tag-line parsing helpers used by both the definition emitter and the +-- documentation renderer. + +---A LuaCATS @param tag's parsed contract. +---@class MoonCatsParamTag +---@field name string Parameter name with any trailing ? stripped. +---@field optional boolean +---@field type string The parameter's type expression. + +---A LuaCATS @return tag's parsed contract. +---@class MoonCatsReturnTag +---@field type string The return's type expression. +---@field name? string The return's name, when documented. +---@field description? string Description text following the name. + +---A LuaCATS @field tag's parsed contract. +---@class MoonCatsFieldTag +---@field name string Field name with any trailing ? stripped. +---@field optional boolean +---@field visibility? string "private", "protected", "public", or "package" when tagged. +---@field type string The field's type expression. +---@field description? string Description text following the type. + +---One variant line of an @alias declaration. +---@class MoonCatsAliasVariant +---@field value string The variant's literal value, verbatim. +---@field key? string The member name from a "# Key: description" comment. +---@field description? string The comment's description text. + +visibilityKeywords = {private: true, protected: true, public: true, package: true} + +---Extracts the leading type expression from a tag line remainder, balancing brackets so +---function types, inline tables, and generics survive; a token ending in ":" or "," pulls +---in the next token (fun return lists). +---@param text string Everything after the tag's name field. +---@return string typeText The type expression, or "any" when none could be read. +---@return string restText The remainder following the type expression, whitespace-trimmed. +extractTypeText = (text) -> + pos, len = 1, #text + depthRound, depthCurly, depthAngle = 0, 0, 0 + readToken = -> + start = pos + while pos <= len + char = text\sub pos, pos + switch char + when "(" then depthRound += 1 + when ")" then depthRound -= 1 + when "{" then depthCurly += 1 + when "}" then depthCurly -= 1 + when "<" then depthAngle += 1 + when ">" then depthAngle -= 1 + when " ", "\t" + break if depthRound <= 0 and depthCurly <= 0 and depthAngle <= 0 + pos += 1 + text\sub start, pos - 1 + + skipSpace = -> + while pos <= len and text\sub(pos, pos)\match "%s" + pos += 1 + + skipSpace! + token = readToken! + return "any", "" if token == "" + -- a token ending in ":" (fun return list) or "," (multi-return continuation) continues + while token\sub(-1) == ":" or token\sub(-1) == "," + skipSpace! + nextToken = readToken! + break if nextToken == "" + token ..= " " .. nextToken + rest = text\sub(pos)\gsub("^%s+", "")\gsub "%s+$", "" + token, rest + +---Parses a ---@param tag line into its contract. +---@param line string +---@return MoonCatsParamTag? tag Nil when the line is not a @param tag. +parseParamTag = (line) -> + name, remainder = line\match "^%-%-%-@param%s+(%S+)%s*(.*)$" + return nil unless name + optional = name\sub(-1) == "?" + name = name\sub 1, -2 if optional + typeText, description = extractTypeText remainder + {:name, :optional, type: typeText, description: #description > 0 and description or nil} + +---Parses a ---@return tag line into its contract. The first word after the type is taken +---as the return's name, matching how the language server reads the tag. +---@param line string +---@return MoonCatsReturnTag? tag Nil when the line is not a @return tag. +parseReturnTag = (line) -> + remainder = line\match "^%-%-%-@return%s+(.*)$" + return nil unless remainder + typeText, rest = extractTypeText remainder + name, description = rest\match "^([%w_%.]+)%s*(.*)$" + if name + {type: typeText, :name, description: #description > 0 and description or nil} + else + {type: typeText, description: #rest > 0 and rest or nil} + +---Parses a ---@field tag line into its contract, honoring a leading visibility keyword. +---@param line string +---@return MoonCatsFieldTag? tag Nil when the line is not a @field tag. +parseFieldTag = (line) -> + rest = line\match "^%-%-%-@field%s+(.*)$" + return nil unless rest + visibility = nil + firstWord = rest\match "^(%l+)%s" + if firstWord and visibilityKeywords[firstWord] + visibility = firstWord + rest = rest\gsub "^%l+%s+", "" + name, remainder = rest\match "^(%S+)%s*(.*)$" + return nil unless name + optional = name\sub(-1) == "?" + name = name\sub 1, -2 if optional + typeText, description = extractTypeText remainder + {:name, :optional, :visibility, type: typeText, description: #description > 0 and description or nil} + +---Parses an @alias declaration's ---| variant lines. +---@param blockLines string[] The alias segment's lines (non-variant lines are skipped). +---@return MoonCatsAliasVariant[] variants In declaration order. +parseAliasVariants = (blockLines) -> + variants = {} + for line in *blockLines + value, comment = line\match "^%-%-%-|%s*(.-)%s*#%s*(.*)$" + value = line\match "^%-%-%-|%s*(.-)%s*$" unless value + continue unless value and #value > 0 + variant = {:value} + if comment + key, description = comment\match "^([%w_]+):%s*(.*)$" + if key + variant.key = key + variant.description = #description > 0 and description or nil + else + variant.description = #comment > 0 and comment or nil + table.insert variants, variant + variants + +---Collects the @param and @return tags of an annotation block. +---@param blockLines? string[] +---@return MoonCatsParamTag[] params In tag order. +---@return MoonCatsReturnTag[] returns In tag order. +collectBlockTags = (blockLines) -> + params, returns = {}, {} + if blockLines + for line in *blockLines + tag = parseParamTag line + if tag + table.insert params, tag + else + returnTag = parseReturnTag line + table.insert returns, returnTag if returnTag + params, returns + +---Reports whether an annotation block documents a function (has @param or @return tags). +---@param blockLines? string[] +---@return boolean documentsFunction +blockDocumentsFunction = (blockLines) -> + return false unless blockLines + for line in *blockLines + return true if line\match("^%-%-%-@param%s") or line\match "^%-%-%-@return%s" + false + +---Reports whether a block already carries an @private tag. +---@param blockLines? string[] +---@return boolean tagged +blockContainsPrivateTag = (blockLines) -> + return false unless blockLines + for line in *blockLines + return true if line\match "^%-%-%-@private" + false + +---Collects a block's prose lines (untagged, non-variant), stripped of the comment lead but +---otherwise verbatim: indentation and blank separator lines survive, so markdown constructs +---like indented code examples keep working. Boundary blank lines are trimmed. +---@param blockLines? string[] +---@return string[] lines In block order; empty when the block has no prose. +proseLines = (blockLines) -> + lines = {} + if blockLines + for line in *blockLines + remainder = line\match "^%-%-%-(.*)$" + continue unless remainder + -- only an immediately following @ or | marks a tag/variant line; an indented + -- one is content (e.g. a @{template} example inside an indented code block) + head = remainder\sub 1, 1 + continue if head == "@" or head == "|" + table.insert lines, remainder + while #lines > 0 and lines[1]\match "^%s*$" + table.remove lines, 1 + while #lines > 0 and lines[#lines]\match "^%s*$" + table.remove lines + lines + +---Joins a block's prose lines into a single-line description for a @field tag. +---@param blockLines? string[] +---@return string prose Empty when the block has no prose. +flattenBlockProse = (blockLines) -> + return "" unless blockLines + parts = {} + for line in *blockLines + prose = line\match "^%-%-%-%s*([^@|%s].*)$" + table.insert parts, prose if prose + table.concat parts, " " + +---Extracts the type expression of a block's own @type tag. +---@param blockLines? string[] +---@return string? typeText Nil when the block carries no @type tag. +blockOwnType = (blockLines) -> + return nil unless blockLines + for line in *blockLines + remainder = line\match "^%-%-%-@type%s+(.*)$" + if remainder + typeText = extractTypeText remainder + return typeText + nil + +---Extracts the text of a block's first occurrence of the given tag. +---@param blockLines? string[] +---@param tag string Tag name without the @ (e.g. "deprecated", "see"). +---@return string? text The tag's trailing text, empty for a bare tag, or nil when the tag is absent. +blockTagText = (blockLines, tag) -> + return nil unless blockLines + for line in *blockLines + text = line\match "^%-%-%-@#{tag}%s*(.*)$" + return text if text + nil + +---Parenthesizes a type expression containing a fun type, whose return list would otherwise +---swallow the comma before the next parameter or return in an assembled signature. +---@param typeText string +---@return string safeText +guardFunType = (typeText) -> + return typeText unless typeText\find "fun(", 1, true + return typeText if typeText\match "^%b()$" + "(#{typeText})" + +---Infers a LuaCATS type for a literal token. +---@param token string +---@param literalKind string Lua type name from the parser. +---@return string typeText +inferLiteralType = (token, literalKind) -> + switch literalKind + when "number" + token\match("^%-?%d+$") and "integer" or "number" + when "string" then "string" + when "boolean" then "boolean" + else "any" + +---Guesses a numeric or string result type for an operator expression from its leaf nodes. +---@param node table +---@return string typeText "number" or "string" from the leaf nodes, or "any" when neither appears. +inferExpressionType = (node) -> + sawNumber, sawString = false, false + walk = (child) -> + return unless "table" == type child + sawNumber = true if child[1] == "number" + sawString = true if child[1] == "string" + for _, grandchild in pairs child + walk grandchild if "table" == type grandchild + walk node + if sawString then "string" + elseif sawNumber then "number" + else "any" + +{ + :extractTypeText, :parseParamTag, :parseReturnTag, :parseFieldTag, :parseAliasVariants + :collectBlockTags, :blockDocumentsFunction, :blockContainsPrivateTag + :proseLines, :flattenBlockProse, :blockOwnType, :blockTagText, :guardFunType + :inferLiteralType, :inferExpressionType +} diff --git a/modules/l0/MoonCats/test.moon b/modules/l0/MoonCats/test.moon new file mode 100644 index 0000000..e75934a --- /dev/null +++ b/modules/l0/MoonCats/test.moon @@ -0,0 +1,15 @@ +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + +UnitTestSuite "l0.MoonCats", (MoonCats, ...) -> + -- The suite controls object is appended by UnitTestSuite\import as the final argument. + nArgs = select "#", ... + controls = select nArgs, ... + + { + Annotations: (controls\requireTest "annotations")! + Parser: (controls\requireTest "Parser")! + Emitter: (controls\requireTest "Emitter")! + DocRenderer: (controls\requireTest "DocRenderer")! + -- the module under test is injected: requiring l0.MoonCats here would recurse into its own load + MoonCats: (controls\requireTest "MoonCats") MoonCats + } diff --git a/modules/l0/MoonCats/test/DocRenderer.moon b/modules/l0/MoonCats/test/DocRenderer.moon new file mode 100644 index 0000000..07cfbc3 --- /dev/null +++ b/modules/l0/MoonCats/test/DocRenderer.moon @@ -0,0 +1,258 @@ +-- DocRenderer tests: page structure, both-form signatures, privacy, cross-links, +-- enum/alias tables, and site scaffolding — all on in-memory snippets. +() -> + Parser = require "l0.MoonCats.Parser" + DocRenderer = require "l0.MoonCats.DocRenderer" + UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + {:receiverName, :linkifyType} = UnitTestSuite\getTestExports DocRenderer + + -- parse snippets and render; returns the result plus a page lookup by require id + renderModules = (ut, modules, opts) -> + parser = Parser! + irs = {} + packageSymbols = {typeNameByRequireId: {}, aliases: {}} + for mod in *modules + ir, err = parser\parse mod.source, mod.requireId + ut\assertNotNil ir, err + table.insert irs, ir + if ir.export.kind == "class" and ir.export.class + packageSymbols.typeNameByRequireId[ir.requireId] = ir.export.class.typeName + for name in pairs ir.aliases + packageSymbols.aliases[name] = true + result = DocRenderer!\render irs, packageSymbols, opts + pageFor = (requireId) -> + for page in *result.pages + return page.text if page.requireId == requireId or page.title == requireId + result, pageFor + + render = (ut, source, requireId = "test.Mod", opts) -> + result, pageFor = renderModules ut, {{:source, :requireId}}, opts + (pageFor requireId), result + + { + _description: "MoonCatsDocRenderer: module IRs to markdown API documentation." + + -- ── page skeleton ─────────────────────────────────────────────────────── + + page_headerAndRequireSnippet: (ut) -> + text = render ut, "class Foo\n go: => 1\nreturn Foo", "l0.Test.Foo" + ut\assertMatches text, "^# l0%.Test%.Foo\n" + ut\assertMatches text, 'Foo = require "l0%.Test%.Foo"' + ut\assertMatches text, 'local Foo = require%("l0%.Test%.Foo"%)' + + prose_indentedExampleFenced: (ut) -> + -- a blank-separated indented run (the annotations' inline-example form) becomes a + -- highlighted fence, dedented + text = render ut, "---Filters things.\n---\n--- Filter!\\include \"x\"\n--- Filter!\\includeAll!\n---@class Filter\nclass Filter\n go: => 1\nreturn Filter" + ut\assertMatches text, "Filters things%.\n\n```moonscript\nFilter!\\include \"x\"\nFilter!\\includeAll!\n```" + + prose_hangingIndentStaysInline: (ut) -> + -- an indented continuation directly under a paragraph line is prose, not code + text = render ut, "class Foo\n ---Compares deeply:\n --- tables must match at identical indexes\n ---@param a any\n go: (a) => a\nreturn Foo" + ut\assertFalse text\match("```moonscript") != nil + ut\assertMatches text, "Compares deeply:\n tables must match at identical indexes" + + page_classProseRendered: (ut) -> + text = render ut, "---A documented class.\n---Second prose line.\n---@class Foo\nclass Foo\n go: => 1\nreturn Foo" + ut\assertMatches text, "## Foo <a name=\"Foo\"></a>\n\nA documented class%.\nSecond prose line%." + + -- ── signatures ────────────────────────────────────────────────────────── + + signature_constructorBothForms: (ut) -> + text = render ut, "class Widget\n ---Creates it.\n ---@param name string The name.\n new: (@name) =>\nreturn Widget" + ut\assertMatches text, "widget = Widget name" + ut\assertMatches text, "local widget = Widget%(name%)" + -- ctor param table carries the description the definitions lose + ut\assertMatches text, "| name | `string` | The name%. |" + + signature_instanceMethodBothForms: (ut) -> + text = render ut, "class Widget\n ---does\n ---@param x integer\n poke: (x) => x\nreturn Widget" + ut\assertMatches text, "widget\\poke x" + ut\assertMatches text, "widget:poke%(x%)" + + signature_zeroArgUsesBang: (ut) -> + text = render ut, "class Widget\n ---does\n peek: => 1\nreturn Widget" + ut\assertMatches text, "widget\\peek!" + ut\assertMatches text, "widget:peek%(%)" + + signature_fatStaticOnClass: (ut) -> + text = render ut, "class Widget\n ---does\n ---@param ns string\n @find = (ns) => ns\nreturn Widget" + ut\assertMatches text, "Widget\\find ns" + ut\assertMatches text, "Widget:find%(ns%)" + + signature_thinStaticDotForm: (ut) -> + text = render ut, "class Widget\n ---does\n ---@param ns string\n @parse = (ns) -> ns\nreturn Widget" + ut\assertMatches text, "Widget%.parse ns" + ut\assertMatches text, "Widget%.parse%(ns%)" + + receiver_lowerCamelAndKeywordSafe: (ut) -> + ut\assertEquals receiverName("UpdateFeed"), "updateFeed" + ut\assertEquals receiverName("Timer"), "timer" + -- a receiver that would be a keyword gets a prefix + ut\assertEquals receiverName("Local"), "theLocal" + + -- ── member sections ───────────────────────────────────────────────────── + + sections_instanceVsClassMethods: (ut) -> + text = render ut, "class Foo\n ---inst\n go: => 1\n ---stat\n @make = -> 1\nreturn Foo" + instancePos = text\find "### Instance methods" + classPos = text\find "### Class methods" + ut\assertNotNil instancePos + ut\assertNotNil classPos + goPos = text\find "#### go" + makePos = text\find "#### make" + ut\assertTrue instancePos < goPos and goPos < classPos + ut\assertTrue classPos < makePos + + returns_namedWithDescriptions: (ut) -> + text = render ut, "class Foo\n ---does\n ---@return boolean ok True when it worked.\n ---@return string? err Failure reason.\n go: => true\nreturn Foo" + ut\assertMatches text, "%*%*Returns:%*%*" + ut\assertMatches text, "%- `ok` `boolean` — True when it worked%." + ut\assertMatches text, "%- `err` `string`%? — Failure reason%." + + deprecated_blockquoteWithReason: (ut) -> + text = render ut, "class Foo\n ---old\n ---@deprecated Use go2 instead.\n ---@param a string\n go: (a) => a\nreturn Foo" + ut\assertMatches text, "> %*%*Deprecated%*%* — Use go2 instead%." + + params_optionalMarkerAndVararg: (ut) -> + text = render ut, "class Foo\n ---does\n ---@param a? integer Count.\n ---@param ... any Extras.\n go: (a, ...) => a\nreturn Foo" + ut\assertMatches text, "| a%? | `integer` | Count%. |" + ut\assertMatches text, "| %.%.%. | `any` | Extras%. |" + + -- ── privacy ───────────────────────────────────────────────────────────── + + private_omittedByDefault: (ut) -> + text = render ut, "class Foo\n ---@private\n __hidden: => 1\n ---documented\n go: => 1\nreturn Foo" + ut\assertFalse text\match("__hidden") != nil + + private_badgedWhenIncluded: (ut) -> + text = render ut, "class Foo\n ---@private\n __hidden: => 1\n go: => 1\nreturn Foo", "test.Mod", {includePrivate: true} + ut\assertMatches text, "#### __hidden 🔒" + + private_fieldsFiltered: (ut) -> + text = render ut, "---@class Foo\n---@field name string Public field.\n---@field private __cache table Internal.\nclass Foo\n go: => 1\nreturn Foo" + ut\assertMatches text, "| name | `string` | Public field%. |" + ut\assertFalse text\match("__cache") != nil + + -- ── fields and data ───────────────────────────────────────────────────── + + fields_accessorPropertyFromClassBlock: (ut) -> + text = render ut, 'Accessors = require "l0.DependencyControl.Accessors"\n---@class Foo\n---@field state integer Read-only view.\nclass Foo\n state: Accessors.property\n get: => 1\nreturn Foo' + ut\assertMatches text, "### Fields" + ut\assertMatches text, "| state | `integer` | Read%-only view%. |" + + fields_dataStaticsTyped: (ut) -> + text = render ut, "class Logger\n new: =>\nclass Foo\n @logger = Logger!\n @maxSize = 200\nreturn Foo" + ut\assertMatches text, "| logger | %[Logger%]%(#Logger%) |" + ut\assertMatches text, "| maxSize | `integer` |" + + -- ── enums and aliases ─────────────────────────────────────────────────── + + enum_tableWithAliasDescriptions: (ut) -> + text = render ut, 'Enum = require "l0.DependencyControl.Enum"\nclass Foo\n ---@alias Mode\n ---| "fast" # Fast: quick and shallow\n ---| "slow" # Slow: careful\n Mode = Enum "Mode", {Fast: "fast", Slow: "slow"}\n @Mode = Mode\nreturn Foo' + ut\assertMatches text, "### Enums" + ut\assertMatches text, "#### Mode" + ut\assertMatches text, "| Fast | `\"fast\"` | quick and shallow |" + ut\assertMatches text, "| Slow | `\"slow\"` | careful |" + + enum_plainValuesWithoutAlias: (ut) -> + text = render ut, 'Enum = require "l0.DependencyControl.Enum"\nclass Foo\n @Status = Enum "Status", {Ok: 1, Failed: -1}\nreturn Foo' + ut\assertMatches text, "| Ok | `1` |" + ut\assertMatches text, "| Failed | `%-1` |" + + types_aliasVariantTable: (ut) -> + text = render ut, "---Precision selector.\n---@alias Precision\n---| 'major' # Major: whole releases\n---| 'minor' # Minor: feature releases\n\nx = 1\n\nf = -> 1\nreturn {f: f}" + ut\assertMatches text, "## Types" + ut\assertMatches text, "### Precision" + ut\assertMatches text, "Precision selector%." + ut\assertMatches text, "| `'major'` | Major: whole releases |" + + types_annotationOnlyClassTable: (ut) -> + text = render ut, "---Constructor arguments.\n---@class FooArgs\n---@field name string The name.\n---@field count? integer How many.\n\nclass Foo\n go: => 1\nreturn Foo" + ut\assertMatches text, "### FooArgs" + ut\assertMatches text, "| name | `string` | The name%. |" + ut\assertMatches text, "| count%? | `integer` | How many%. |" + + -- ── cross-links ───────────────────────────────────────────────────────── + + links_crossModuleTypeResolves: (ut) -> + _, pageFor = renderModules ut, { + {requireId: "l0.Test.Logger", source: "---@class Logger\nclass Logger\n go: => 1\nreturn Logger"} + {requireId: "l0.Test.Main", source: 'Logger = require "l0.Test.Logger"\nclass Main\n ---does\n ---@param logger Logger The logger.\n run: (logger) => 1\nreturn Main'} + } + mainText = pageFor "l0.Test.Main" + ut\assertMatches mainText, "%[Logger%]%(l0%.Test%.Logger%.md#Logger%)" + + links_unknownTypeStaysCode: (ut) -> + -- the union pipe is escaped because linkified types land in table cells + ut\assertEquals linkifyType("string|integer", {linkIndex: {}, currentPage: "x.md"}), "`string`\\|`integer`" + + links_complexTypeSingleSpan: (ut) -> + rendered = linkifyType "fun(a: string): boolean", {linkIndex: {}, currentPage: "x.md"} + ut\assertEquals rendered, "`fun(a: string): boolean`" + + -- ── duplicates ────────────────────────────────────────────────────────── + + duplicates_documentedWins: (ut) -> + text = render ut, "sleepImpl = (ms) -> ms\nclass Timer\n ---Sleeps.\n ---@param ms number Milliseconds.\n sleep: sleepImpl\n @sleep = sleepImpl\nreturn Timer" + _, count = text\gsub "#### sleep", "" + ut\assertEquals count, 1 + ut\assertMatches text, "| ms | `number` | Milliseconds%. |" + + -- ── module shapes ─────────────────────────────────────────────────────── + + module_tableWithFunctionsAndFields: (ut) -> + text = render ut, '{\n NAME: "depctrl"\n ---Greets.\n ---@param who string\n greet: (who) -> who\n}', "l0.Test.Constants" + ut\assertMatches text, "## Functions" + ut\assertMatches text, "Constants%.greet who" + ut\assertMatches text, "## Fields" + ut\assertMatches text, "| NAME | `string` |" + + module_functionExport: (ut) -> + text = render ut, "---Resolves a host.\n---@param host string The host.\n---@return string resolved The resolution.\nresolveHost = (host) -> host\nreturn resolveHost", "l0.Test.resolve-host" + ut\assertMatches text, "#### resolveHost" + ut\assertMatches text, "resolveHost host" + ut\assertMatches text, "resolveHost%(host%)" + + -- ── index and scaffolding ─────────────────────────────────────────────── + + index_groupsByPackage: (ut) -> + result = nil + do + parser = Parser! + ir = parser\parse "class Foo\n go: => 1\nreturn Foo", "l0.Test.Foo" + result = DocRenderer!\render {ir}, nil, { + siteName: "My API" + packages: { + "l0.Test": {name: "Test Package", version: "1.0.0", description: "A test.", modules: {"l0.Test.Foo"}} + } + } + ut\assertMatches result.indexPage.text, "# My API" + ut\assertMatches result.indexPage.text, "## Test Package `v1%.0%.0`" + ut\assertMatches result.indexPage.text, "%- %[l0%.Test%.Foo%]%(l0%.Test%.Foo%.md%)" + + scaffold_mkdocsDefault: (ut) -> + _, result = render ut, "class Foo\n go: => 1\nreturn Foo", "l0.Test.Foo" + ut\assertEquals #result.scaffold, 1 + ut\assertEquals result.scaffold[1].path, "mkdocs.yml" + ut\assertMatches result.scaffold[1].text, "site_name: API Documentation" + ut\assertMatches result.scaffold[1].text, "%- l0%.Test%.Foo: l0%.Test%.Foo%.md" + -- the mike version-provider block powers the published site's version picker + ut\assertMatches result.scaffold[1].text, "provider: mike" + + scaffold_mdbook: (ut) -> + _, result = render ut, "class Foo\n go: => 1\nreturn Foo", "l0.Test.Foo", {site: "mdbook", siteName: "Book"} + paths = [file.path for file in *result.scaffold] + -- SUMMARY.md must live inside the source dir; book.toml beside it at the output root + ut\assertItemsEqual paths, {"docs/SUMMARY.md", "book.toml"} + for file in *result.scaffold + if file.path == "docs/SUMMARY.md" + ut\assertMatches file.text, "%- %[l0%.Test%.Foo%]%(l0%.Test%.Foo%.md%)" + else + ut\assertMatches file.text, 'title = "Book"' + ut\assertMatches file.text, 'src = "docs"' + + scaffold_noneEmitsNothing: (ut) -> + _, result = render ut, "class Foo\n go: => 1\nreturn Foo", "l0.Test.Foo", {site: "none"} + ut\assertEquals #result.scaffold, 0 + } diff --git a/modules/l0/MoonCats/test/Emitter.moon b/modules/l0/MoonCats/test/Emitter.moon new file mode 100644 index 0000000..416352d --- /dev/null +++ b/modules/l0/MoonCats/test/Emitter.moon @@ -0,0 +1,251 @@ +-- Emitter tests: definition structure, type-text extraction, overload/enum/field synthesis, +-- duplicate folding, and contract diagnostics — all on in-memory snippets. +() -> + Parser = require "l0.MoonCats.Parser" + Emitter = require "l0.MoonCats.Emitter" + Diagnostics = require "l0.MoonCats.Diagnostics" + UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + {:inferLiteralType, :moduleVarName} = UnitTestSuite\getTestExports Emitter + FindingCode = Diagnostics.FindingCode + + -- parse + emit a snippet, asserting the definition is loadable Lua + emit = (ut, source, requireId = "test.mod", packageSymbols) -> + parser, emitter = Parser!, Emitter! + diagnostics = Diagnostics! + ir, err = parser\parse source, requireId, diagnostics + ut\assertNotNil ir, err + text = emitter\emit ir, packageSymbols, diagnostics + ut\assertNotNil (loadstring or load)(text), "definition failed to load: #{text}" + text, diagnostics + + hasFinding = (diagnostics, code) -> + for finding in *diagnostics.findings + return true if finding.code == code + false + + { + _description: "MoonCatsEmitter: module IR to LuaLS .d.lua definition text." + + -- ── definition skeleton ─────────────────────────────────────────────────────── + + skeleton_metaHeaderAndReturn: (ut) -> + text = emit ut, "class Foo\n go: => 1\nreturn Foo", "l0.Test.Foo" + ut\assertMatches text, "^%-%-%-@meta l0%.Test%.Foo\n" + ut\assertMatches text, "\nreturn Foo\n$" + ut\assertMatches text, "\nlocal Foo = {}\n" + + skeleton_methodsAndStatics: (ut) -> + text = emit ut, "class Foo\n ---does a thing\n ---@param a string\n go: (a) => a\n ---@param b integer\n @make = (b) -> b\n ---@param c integer\n @fat = (c) => c\nreturn Foo" + ut\assertMatches text, "function Foo:go%(a%) end" + ut\assertMatches text, "function Foo%.make%(b%) end" + ut\assertMatches text, "function Foo:fat%(c%) end" + + skeleton_metamethodsSkipped: (ut) -> + text = emit ut, "class Foo\n __tostring: => \"x\"\n go: => 1\nreturn Foo" + ut\assertFalse text\match("__tostring") != nil + + -- ── type inference (tag parsing itself is covered by the annotations suite) ── + + inferLiteral_types: (ut) -> + ut\assertEquals inferLiteralType("42", "number"), "integer" + ut\assertEquals inferLiteralType("-1", "number"), "integer" + ut\assertEquals inferLiteralType("1.5", "number"), "number" + ut\assertEquals inferLiteralType("\"x\"", "string"), "string" + + moduleVar_sanitizesLeaf: (ut) -> + ut\assertEquals moduleVarName("l0.DependencyControl.helpers.ffi-posix"), "ffi_posix" + ut\assertEquals moduleVarName("l0.DependencyControl.Constants"), "Constants" + + -- ── constructor overload synthesis ────────────────────────────────────── + + overload_typedFromCtorParams: (ut) -> + text = emit ut, "class Foo\n ---Creates it.\n ---@param name string The name.\n ---@param opts? table Options.\n new: (@name, opts = {}) =>\nreturn Foo" + ut\assertMatches text, "%-%-%-@overload fun%(name: string, opts%?: table%): Foo" + -- constructor prose folds into the class doc; no .new function is emitted + ut\assertMatches text, "%-%-%-Creates it%." + ut\assertFalse text\match("function Foo[%.:]new") != nil + + overload_funTypedParamParenthesized: (ut) -> + -- a fun type's return list would swallow the comma before the next parameter + text = emit ut, "class Foo\n ---Creates it.\n ---@param cb fun(x: string): boolean The callback.\n ---@param name string\n new: (cb, name) =>\nreturn Foo" + ut\assertMatches text, "%-%-%-@overload fun%(cb: %(fun%(x: string%): boolean%), name: string%): Foo" + + overload_varargAndUndocumented: (ut) -> + text = emit ut, "class Foo\n ---@param a string\n ---@param ... any extras\n new: (a, ...) =>\nreturn Foo" + ut\assertMatches text, "%-%-%-@overload fun%(a: string, %.%.%.: any%): Foo" + + overload_noCtorPlainClass: (ut) -> + text = emit ut, "class Foo\n go: => 1\nreturn Foo" + ut\assertMatches text, "%-%-%-@overload fun%(%): Foo" + + overload_noCtorSubclassFallsBack: (ut) -> + text, diagnostics = emit ut, "class Base\n new: =>\nclass Foo extends Base\n go: => 1\nreturn Foo" + ut\assertMatches text, "%-%-%-@overload fun%(%.%.%.%): Foo" + ut\assertTrue hasFinding diagnostics, FindingCode.CtorOverloadFallback + + -- ── class header ──────────────────────────────────────────────────────── + + header_verbatimBlockKept: (ut) -> + text = emit ut, "---A documented class.\n---@class Foo\n---@field bar string The bar.\nclass Foo\n go: => 1\nreturn Foo" + ut\assertMatches text, "%-%-%-A documented class%.\n%-%-%-@class Foo\n%-%-%-@field bar string The bar%." + + header_parentReattachedWhenOmitted: (ut) -> + text = emit ut, "class Base\n new: =>\n---@class Foo\nclass Foo extends Base\n go: => 1\nreturn Foo" + ut\assertMatches text, "%-%-%-@class Foo: Base" + + header_parentSynthesizedWithoutBlock: (ut) -> + text = emit ut, "class Base\n new: =>\nclass Foo extends Base\n go: => 1\nreturn Foo" + ut\assertMatches text, "%-%-%-@class Foo: Base" + + header_instanceDefaultFieldsSynthesized: (ut) -> + text = emit ut, "class Foo\n toFile: false, maxSize: 200\n name: \"x\"\nreturn Foo" + ut\assertMatches text, "%-%-%-@field toFile boolean" + ut\assertMatches text, "%-%-%-@field maxSize integer" + ut\assertMatches text, "%-%-%-@field name string" + + header_declaredFieldNotDuplicated: (ut) -> + text = emit ut, "---@class Foo\n---@field toFile boolean Whether to log to file.\nclass Foo\n toFile: false\nreturn Foo" + _, count = text\gsub "%-%-%-@field toFile", "" + ut\assertEquals count, 1 + + -- ── data members ──────────────────────────────────────────────────────── + + data_fieldsSynthesizedWithInference: (ut) -> + text = emit ut, "class Foo\n @answer = 42\n @label = \"x\"\n @factor = 1.5\n @flag = false\n @lookup = {a: 1}\nreturn Foo" + ut\assertMatches text, "%-%-%-@field answer integer" + ut\assertMatches text, "%-%-%-@field label string" + ut\assertMatches text, "%-%-%-@field factor number" + ut\assertMatches text, "%-%-%-@field flag boolean" + ut\assertMatches text, "%-%-%-@field lookup table" + + data_ownTypeBlockWins: (ut) -> + text = emit ut, "class Foo\n ---limits per crawl\n ---@type table<string, integer>\n @limits = {depth: 2}\nreturn Foo" + -- the block's own @type and its prose merge into the synthesized field + ut\assertMatches text, "%-%-%-@field limits table<string, integer> limits per crawl" + + data_functionContractBecomesFunction: (ut) -> + text = emit ut, "equalsImpl = (a, b) -> a == b\nclass Foo\n ---Compares deeply.\n ---@param a any\n ---@param b any\n ---@return boolean equal\n @equals = equalsImpl\nreturn Foo" + ut\assertMatches text, "function Foo%.equals%(a, b%) end" + + data_classReExportResolvesAcrossModules: (ut) -> + packageSymbols = {typeNameByRequireId: {["l0.Other.Common"]: "OtherCommon"}, aliases: {}} + text = emit ut, 'Common = require "l0.Other.Common"\nclass Foo\n @Common = Common\nreturn Foo', "test.mod", packageSymbols + ut\assertMatches text, "%-%-%-@field Common OtherCommon" + + data_callResolvesToClassInstance: (ut) -> + text = emit ut, "class Logger\n new: =>\nclass Foo\n @logger = Logger!\nreturn Foo" + ut\assertMatches text, "%-%-%-@field logger Logger" + + data_unresolvedTypedAnyWithWarning: (ut) -> + text, diagnostics = emit ut, "mystery = doSomething!\nclass Foo\n @thing = mystery\nreturn Foo" + ut\assertMatches text, "%-%-%-@field thing any" + ut\assertTrue hasFinding diagnostics, FindingCode.UnresolvedReference + + data_privateSynthesizedForDunder: (ut) -> + text = emit ut, "class Foo\n @__instances = 42\nreturn Foo" + ut\assertMatches text, "%-%-%-@field private __instances integer" + + -- ── enum synthesis ────────────────────────────────────────────────────── + + enum_aliasTypedFields: (ut) -> + text = emit ut, 'Enum = require "l0.DependencyControl.Enum"\n---@alias Mode\n---| "fast" # Fast: quick\n---| "slow" # Slow: careful\nMode = Enum "Mode", {Fast: "fast", Slow: "slow"}\nclass Foo\n @Mode = Mode\nreturn Foo' + ut\assertMatches text, "%-%-%-@class ModeEnum: Enum\n%-%-%-@field Fast Mode\n%-%-%-@field Slow Mode" + ut\assertMatches text, "%-%-%-@field Mode ModeEnum" + + enum_literalFallbackWithoutAlias: (ut) -> + text = emit ut, 'Enum = require "l0.DependencyControl.Enum"\nclass Foo\n @Status = Enum "Status", {Ok: 1, Failed: -1, Named: "n"}\nreturn Foo' + ut\assertMatches text, "%-%-%-@field Ok 1" + -- negative literals are not valid LuaCATS types; they fall back to integer + ut\assertMatches text, "%-%-%-@field Failed integer" + ut\assertMatches text, "%-%-%-@field Named \"n\"" + + enum_unexportedNotSynthesized: (ut) -> + text = emit ut, 'Enum = require "l0.DependencyControl.Enum"\nOp = Enum "Op", {Eq: "="}\nclass Foo\n go: => 1\nreturn Foo' + ut\assertFalse text\match("OpEnum") != nil + + enum_computedKeysReported: (ut) -> + _, diagnostics = emit ut, 'Enum = require "l0.DependencyControl.Enum"\nkey = "K"\nclass Foo\n @Map = Enum "Map", {[key]: "v", Plain: "p"}\nreturn Foo' + ut\assertTrue hasFinding diagnostics, FindingCode.ComputedKeySkipped + + enum_augmentationEmitsOntoClass: (ut) -> + text = emit ut, 'Enum = require "l0.DependencyControl.Enum"\nStatus = Enum "Status", {Ok: 0}\nclass Task\n go: => 1\nTask.Status = Status\nreturn Task' + ut\assertMatches text, "%-%-%-@class StatusEnum: Enum" + ut\assertMatches text, "%-%-%-@field Status StatusEnum" + + -- ── duplicate members ─────────────────────────────────────────────────── + + duplicate_documentedBeatsUndocumented: (ut) -> + text = emit ut, "sleepImpl = (ms) -> ms\nclass Timer\n ---Sleeps.\n ---@param ms number\n sleep: sleepImpl\n @sleep = sleepImpl\nreturn Timer" + _, count = text\gsub "Timer%.sleep", "" + ut\assertEquals count, 1 + ut\assertMatches text, "function Timer%.sleep%(ms%) end" + + duplicate_documentedPairFoldsToOverload: (ut) -> + text, diagnostics = emit ut, "class Ver\n ---Packs this instance.\n ---@return integer packed\n toPacked: => 1\n ---Packs any value.\n ---@param value number|string\n ---@return integer packed\n @toPacked = (value) => 1\nreturn Ver" + -- the richer static wins; the instance form survives as a self-typed overload + ut\assertMatches text, "function Ver:toPacked%(value%) end" + ut\assertMatches text, "%-%-%-@overload fun%(self: Ver%): integer" + ut\assertTrue hasFinding diagnostics, FindingCode.DuplicateMemberFolded + + -- ── table, function, and multi-class modules ──────────────────────────── + + module_tableWithoutAnnotations: (ut) -> + text = emit ut, '{\n NAME: "depctrl"\n COUNT: 42\n}', "l0.Test.Constants" + ut\assertMatches text, "%-%-%-@class l0%.Test%.Constants\n%-%-%-@field NAME string\n%-%-%-@field COUNT integer\nlocal Constants = {}" + ut\assertMatches text, "return Constants\n$" + + module_tableWithFunctionField: (ut) -> + text = emit ut, 'return {\n ---Builds a mode string.\n ---@param user? string\n ---@return string mode\n getFileMode: (user = "") -> user\n}', "l0.Test.ffi-posix" + ut\assertMatches text, "function ffi_posix%.getFileMode%(user%) end" + + module_functionExport: (ut) -> + text = emit ut, "---Resolves.\n---@param host string\n---@return string resolved\nresolveHost = (host) -> host\nreturn resolveHost" + ut\assertMatches text, "local function resolveHost%(host%) end" + ut\assertMatches text, "return resolveHost\n$" + + module_multiClassEmitsBoth: (ut) -> + text = emit ut, "class Download\n go: => 1\nclass Downloader\n run: => 1\nreturn Downloader" + ut\assertMatches text, "local Download = {}" + ut\assertMatches text, "local Downloader = {}" + ut\assertMatches text, "return Downloader\n$" + + module_augmentedTableFunction: (ut) -> + text = emit ut, 'wrapper = setmetatable {}, {}\n---Encodes a value.\n---@param value any\n---@return string json\nwrapper.encode = (value) -> "{}"\nwrapper.__depCtrlInit = (DepCtrl) -> nil\nreturn wrapper', "l0.Test.dkjson" + ut\assertMatches text, "function wrapper%.encode%(value%) end" + -- dunder augmentations get a synthesized @private + ut\assertMatches text, "%-%-%-@private\nfunction wrapper%.__depCtrlInit%(DepCtrl%) end" + + -- ── tag passthrough ───────────────────────────────────────────────────── + + passthrough_genericAndDeprecated: (ut) -> + text = emit ut, "class Foo\n ---@deprecated Use other instead.\n ---@param x string\n old: (x) => x\n ---@generic T\n ---@param mod T\n ---@return T mod\n @wrap = (mod) -> mod\nreturn Foo" + ut\assertMatches text, "%-%-%-@deprecated Use other instead%." + ut\assertMatches text, "%-%-%-@generic T" + + -- ── contract diagnostics ──────────────────────────────────────────────── + + check_missingDocOnPublicFunction: (ut) -> + _, diagnostics = emit ut, "class Foo\n go: (a) => a\nreturn Foo" + ut\assertTrue hasFinding diagnostics, FindingCode.MissingDoc + ut\assertTrue diagnostics\hasCheckFailures! + + check_privateExemptFromMissingDoc: (ut) -> + _, diagnostics = emit ut, "class Foo\n __helper: (a) => a\n ---documented\n go: => 1\nreturn Foo" + ut\assertFalse hasFinding diagnostics, FindingCode.MissingDoc + + check_paramNameMismatch: (ut) -> + _, diagnostics = emit ut, "class Foo\n ---does\n ---@param wrongName string\n go: (a) => a\nreturn Foo" + ut\assertTrue hasFinding diagnostics, FindingCode.ParamNameMismatch + + check_paramCountMismatch: (ut) -> + _, diagnostics = emit ut, "class Foo\n ---does\n ---@param a string\n go: (a, b) => a\nreturn Foo" + ut\assertTrue hasFinding diagnostics, FindingCode.ParamCountMismatch + + check_missingReturn: (ut) -> + _, diagnostics = emit ut, "class Foo\n ---does\n ---@param a string\n go: (a) => return a\nreturn Foo" + ut\assertTrue hasFinding diagnostics, FindingCode.MissingReturn + + check_cleanContractNoErrors: (ut) -> + _, diagnostics = emit ut, "class Foo\n ---does\n ---@param a string\n ---@return string a\n go: (a) => return a\nreturn Foo" + ut\assertFalse diagnostics\hasCheckFailures! + } diff --git a/modules/l0/MoonCats/test/MoonCats.moon b/modules/l0/MoonCats/test/MoonCats.moon new file mode 100644 index 0000000..9e1ef77 --- /dev/null +++ b/modules/l0/MoonCats/test/MoonCats.moon @@ -0,0 +1,82 @@ +-- MoonCats orchestrator tests: the two-pass package extraction, cross-module symbol +-- resolution, and the parse-failure path. +-- Receives the module under test from the suite root (a direct require would recurse +-- into l0.MoonCats' own load, which registers this suite). +(MoonCats) -> + Diagnostics = require "l0.MoonCats.Diagnostics" + UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + {:buildPackageSymbols} = UnitTestSuite\getTestExports MoonCats + FindingCode = Diagnostics.FindingCode + + definitionFor = (result, requireId) -> + for def in *result.definitions + return def.text if def.requireId == requireId + + { + _description: "MoonCats: two-pass type-definition extraction over module sets." + + extractPackage_crossModuleTyping: (ut) -> + -- the re-exported Common static resolves to the other module's annotated class name + result = MoonCats!\extractPackage { + {requireId: "l0.Test.Common", source: "---@class TestCommon\nclass Common\n ---validates\n ---@param ns string\n ---@return boolean ok\n @validate = (ns) -> true\nreturn Common"} + {requireId: "l0.Test.Main", source: 'Common = require "l0.Test.Common"\nclass Main\n @Common = Common\nreturn Main'} + } + ut\assertEquals #result.definitions, 2 + mainDef = definitionFor result, "l0.Test.Main" + ut\assertMatches mainDef, "%-%-%-@field Common TestCommon" + ut\assertFalse result.diagnostics\hasCheckFailures! + + extractPackage_aliasesSharedAcrossModules: (ut) -> + -- an alias declared in one module types enum fields in another + result = MoonCats!\extractPackage { + {requireId: "l0.Test.Types", source: "---@alias Mode\n---| \"fast\" # Fast: quick\nx = 1\n\nf = -> 1\nreturn {f: f}"} + {requireId: "l0.Test.Owner", source: 'Enum = require "l0.DependencyControl.Enum"\nclass Owner\n @Mode = Enum "Mode", {Fast: "fast"}\nreturn Owner'} + } + ownerDef = definitionFor result, "l0.Test.Owner" + ut\assertMatches ownerDef, "%-%-%-@field Fast Mode" + + extractPackage_parseFailureIsolated: (ut) -> + -- a broken module yields a finding; the healthy one still emits + result = MoonCats!\extractPackage { + {requireId: "l0.Test.Broken", source: "class {{{"} + {requireId: "l0.Test.Fine", source: "class Fine\n ---documented\n go: => 1\nreturn Fine"} + } + ut\assertEquals #result.definitions, 1 + ut\assertEquals result.definitions[1].requireId, "l0.Test.Fine" + found = false + for finding in *result.diagnostics.findings + found = true if finding.code == FindingCode.ParseFailure and finding.requireId == "l0.Test.Broken" + ut\assertTrue found + ut\assertTrue result.diagnostics\hasCheckFailures! + + extractModule_matchesSingleModulePackage: (ut) -> + source = "class Solo\n ---documented\n go: => 1\nreturn Solo" + definitionText, diagnostics = MoonCats!\extractModule source, "l0.Test.Solo" + result = MoonCats!\extractPackage {{requireId: "l0.Test.Solo", source: source}} + ut\assertEquals definitionText, result.definitions[1].text + ut\assertFalse diagnostics\hasCheckFailures! + + extractModule_nilOnParseFailure: (ut) -> + definitionText, diagnostics = MoonCats!\extractModule "class {{{", "l0.Test.Broken" + ut\assertNil definitionText + ut\assertTrue diagnostics\hasCheckFailures! + + renderDocs_producesPagesAndScaffold: (ut) -> + result, diagnostics = MoonCats!\renderDocs { + {requireId: "l0.Test.Solo", source: "class Solo\n ---documented\n go: => 1\nreturn Solo"} + }, {siteName: "Smoke API"} + ut\assertEquals #result.pages, 1 + ut\assertMatches result.pages[1].text, "# l0%.Test%.Solo" + ut\assertMatches result.indexPage.text, "# Smoke API" + ut\assertEquals result.scaffold[1].path, "mkdocs.yml" + ut\assertFalse diagnostics\hasCheckFailures! + + buildPackageSymbols_collectsTypeNamesAndAliases: (ut) -> + parser = MoonCats.Parser! + irA = parser\parse "---@class RealA\nclass A\n go: => 1\nreturn A", "l0.Test.A" + irB = parser\parse "{\n X: 1\n}", "l0.Test.B" + packageSymbols = buildPackageSymbols {irA, irB} + ut\assertEquals packageSymbols.typeNameByRequireId["l0.Test.A"], "RealA" + -- table modules type as their require identifier + ut\assertEquals packageSymbols.typeNameByRequireId["l0.Test.B"], "l0.Test.B" + } diff --git a/modules/l0/MoonCats/test/Parser.moon b/modules/l0/MoonCats/test/Parser.moon new file mode 100644 index 0000000..e02ea50 --- /dev/null +++ b/modules/l0/MoonCats/test/Parser.moon @@ -0,0 +1,316 @@ +-- Parser tests: comment-block scanning, string masking, segmentation, block binding, +-- member classification, export unwrapping, and enum recognition — all on in-memory snippets. +() -> + Parser = require "l0.MoonCats.Parser" + Diagnostics = require "l0.MoonCats.Diagnostics" + UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" + {:splitLines, :computeStringMask, :scanCommentBlocks, :segmentBlock, + :paramsFromFndef, :literalTokenFromNode, :fndefHasValueReturn} = UnitTestSuite\getTestExports Parser + {:MemberKind, :ExportKind, :SegmentKind, :ValueKind, :SymbolKind} = Parser + + parse = (source, requireId = "test.mod") -> + parser = Parser! + diagnostics = Diagnostics! + ir, err = parser\parse source, requireId, diagnostics + ir, diagnostics, err + + memberByName = (cls, name) -> + for member in *cls.members + return member if member.name == name + + { + _description: "MoonCatsParser: MoonScript source to definition-extraction IR." + + -- ── block scanning ────────────────────────────────────────────────────── + + scan_boundAndStandaloneBlocks: (ut) -> + ir = parse "---standalone prose block\n\n---bound to f\nf = -> 1\n\nreturn {f: f}" + -- the bound block landed on the local; the standalone one has no alias/class segments, + -- so its rest is dropped and nothing hoists + ut\assertEquals #ir.segments, 0 + ut\assertTable ir.symbols.f + ut\assertEquals ir.symbols.f.block[1], "---bound to f" + + scan_plainCommentTerminatesBlock: (ut) -> + -- a plain -- line between doc lines splits them into two blocks and joins neither + ir = parse "---@alias Foo\n---| \"a\" # A: first\n-- plain divider\n---bound doc\nf = -> 1\nreturn {f: f}" + ut\assertEquals #ir.segments, 1 + ut\assertEquals ir.segments[1].name, "Foo" + ut\assertEquals ir.symbols.f.block[1], "---bound doc" + + scan_dashDividerNotDoc: (ut) -> + blocks = scanCommentBlocks splitLines("---- divider\n---real doc\nx = 1"), {} + ut\assertEquals #blocks, 1 + ut\assertEquals blocks[1].lines[1], "---real doc" + + scan_privateOnlyBlockBinds: (ut) -> + ir = parse "class Foo\n ---@private\n __hidden: => 1\nreturn Foo" + member = memberByName ir.classes[1], "__hidden" + ut\assertTrue member.isPrivate + ut\assertEquals member.block[1], "---@private" + + scan_untaggedContinuationKept: (ut) -> + ir = parse "class Foo\n ---@param x string a wrapped description\n ---that continues on the next line.\n go: (x) => x\nreturn Foo" + member = memberByName ir.classes[1], "go" + ut\assertEquals #member.block, 2 + ut\assertMatches member.block[2], "continues" + + -- ── string masking ────────────────────────────────────────────────────── + + mask_longStringNeverScanned: (ut) -> + ir = parse "x = [==[\n---@class NotReal\n---@alias NotAnAlias\n]==]\n\nreturn {x: x}" + ut\assertEquals #ir.segments, 0 + + mask_coversLongStringSpan: (ut) -> + source = "x = [[\nline\n---@class Fake\n]]\ny = 1" + moonParse = require "moonscript.parse" + tree = moonParse.string source + mask = computeStringMask tree, source + ut\assertTrue mask[3] + + -- ── segmentation ──────────────────────────────────────────────────────── + + segment_aliasClaimsLeadingProse: (ut) -> + segments = segmentBlock {startLine: 1, endLine: 3, lines: { + "---Alias description.", "---@alias Mode", "---| \"a\" # A: first" + }} + ut\assertEquals #segments, 1 + ut\assertEquals segments[1].kind, SegmentKind.Alias + ut\assertEquals segments[1].name, "Mode" + ut\assertEquals #segments[1].lines, 3 + + segment_proseAfterVariantsFallsToRest: (ut) -> + segments = segmentBlock {startLine: 1, endLine: 4, lines: { + "---@alias Mode", "---| \"a\" # A: first", "---Member doc that follows the alias.", "---@type string" + }} + ut\assertEquals #segments, 2 + ut\assertEquals segments[2].kind, SegmentKind.Rest + ut\assertEquals segments[2].lines[1], "---Member doc that follows the alias." + ut\assertEquals segments[2].lines[2], "---@type string" + + segment_annotationOnlyClassKeepsFields: (ut) -> + segments = segmentBlock {startLine: 1, endLine: 3, lines: { + "---@class Args", "---@field name string The name.", "---@field count? integer" + }} + ut\assertEquals #segments, 1 + ut\assertEquals segments[1].kind, SegmentKind.Class + ut\assertEquals #segments[1].lines, 3 + + segment_detachedClassBlocksHoist: (ut) -> + ir = parse "---@class PartialVersion\n---@field major? integer\n\n---@class Interval\n---@field min integer\n\nmsgs = {a: 1}\n\nf = -> 1\nreturn {f: f}" + ut\assertEquals #ir.segments, 2 + ut\assertEquals ir.segments[1].name, "PartialVersion" + ut\assertEquals ir.segments[2].name, "Interval" + + segment_nestedAnnotationOnlyClassInBody: (ut) -> + ir = parse "class Foo\n ---@class Spec\n ---@field url string\n\n data = {url: 1}\n\n go: => 1\nreturn Foo" + ut\assertEquals #ir.segments, 1 + ut\assertEquals ir.segments[1].name, "Spec" + + segment_aliasAboveEnumHoistsAndRegisters: (ut) -> + ir = parse 'Enum = require "l0.DependencyControl.Enum"\n---@alias Mode\n---| "fast" # Fast: quick\nMode = Enum "Mode", {Fast: "fast"}\nreturn {Mode: Mode}' + ut\assertTrue ir.aliases.Mode + ut\assertEquals #ir.enums, 1 + ut\assertEquals ir.enums[1].name, "Mode" + + -- ── classification ────────────────────────────────────────────────────── + + classify_staticShapes: (ut) -> + ir = parse "class Foo\n @fat = (a) => a\n @thin = (a) -> a\n @colon: (a) => a\n @answer = 42\nreturn Foo" + cls = ir.classes[1] + ut\assertEquals memberByName(cls, "fat").kind, MemberKind.StaticMethodFat + ut\assertEquals memberByName(cls, "thin").kind, MemberKind.StaticMethodThin + ut\assertEquals memberByName(cls, "colon").kind, MemberKind.StaticMethodFat + ut\assertEquals memberByName(cls, "answer").kind, MemberKind.StaticData + + classify_signatureTraps: (ut) -> + ir = parse [==[ +class Foo + describe: (values = @values, pattern = ((key, value) -> "#{value} (#{key})"), join = true) => + join + new: (@name, @f = -> , @testClass) => +return Foo]==] + cls = ir.classes[1] + describeMember = memberByName cls, "describe" + ut\assertEquals #describeMember.params, 3 + ut\assertEquals describeMember.params[2].name, "pattern" + ut\assertTrue describeMember.params[2].hasDefault + ctor = memberByName cls, "new" + ut\assertEquals ctor.kind, MemberKind.Constructor + ut\assertItemsEqual [p.name for p in *ctor.params], {"name", "f", "testClass"} + + classify_zeroArgNoParens: (ut) -> + ir = parse "class Foo\n @make = =>\n peek: =>\nreturn Foo" + cls = ir.classes[1] + ut\assertEquals #memberByName(cls, "make").params, 0 + ut\assertEquals #memberByName(cls, "peek").params, 0 + + classify_selfParamsStrippedAndVarargs: (ut) -> + ir = parse "class Foo\n go: (@a, @@b, c, ...) => c\nreturn Foo" + params = memberByName(ir.classes[1], "go").params + ut\assertItemsEqual [p.name for p in *params], {"a", "b", "c", "..."} + ut\assertTrue params[4].isVararg + + classify_fieldParamUnderscoresDropped: (ut) -> + -- an @field param's leading underscores mark the field private, not the argument + ir = parse "class Foo\n new: (@__resolver, @_opts, plain) =>\nreturn Foo" + params = memberByName(ir.classes[1], "new").params + ut\assertItemsEqual [p.name for p in *params], {"resolver", "opts", "plain"} + + classify_twoPerLineInstanceDefaults: (ut) -> + ir = parse "class Foo\n toFile: false, toWindow: true\nreturn Foo" + cls = ir.classes[1] + ut\assertEquals memberByName(cls, "toFile").kind, MemberKind.InstanceDefault + ut\assertEquals memberByName(cls, "toFile").valueInfo.literalKind, "boolean" + ut\assertEquals memberByName(cls, "toWindow").kind, MemberKind.InstanceDefault + + classify_accessorPropertyRecognized: (ut) -> + ir = parse 'Accessors = require "l0.DependencyControl.Accessors"\nclass Foo\n state: Accessors.property\n get: => 1\nreturn Foo' + ut\assertEquals memberByName(ir.classes[1], "state").kind, MemberKind.AccessorProperty + + classify_metamethodVsPrivate: (ut) -> + ir = parse "class Foo\n __tostring: => \"Foo\"\n __helper: => 1\nreturn Foo" + cls = ir.classes[1] + ut\assertEquals memberByName(cls, "__tostring").kind, MemberKind.Metamethod + helper = memberByName cls, "__helper" + ut\assertEquals helper.kind, MemberKind.Method + ut\assertTrue helper.isPrivate + + classify_classLocalsInvisible: (ut) -> + ir = parse 'class Foo\n msgs = {err: "%s"}\n helper = (a) -> a\n go: => 1\nreturn Foo' + cls = ir.classes[1] + ut\assertNil memberByName cls, "msgs" + ut\assertNil memberByName cls, "helper" + ut\assertEquals cls.localSymbols.helper.kind, SymbolKind.Function + + classify_computedKeyReported: (ut) -> + ir, diagnostics = parse "class Foo\n [someVar]: 1\n go: => 1\nreturn Foo" + ut\assertNil memberByName ir.classes[1], "someVar" + found = false + for finding in *diagnostics.findings + found = true if finding.code == Diagnostics.FindingCode.ComputedKeySkipped + ut\assertTrue found + + classify_classAnnotationOverridesTypeName: (ut) -> + ir = parse "---@class RealName\nclass FileName\n go: => 1\nreturn FileName" + ut\assertEquals ir.classes[1].typeName, "RealName" + ut\assertEquals ir.classes[1].name, "FileName" + + classify_privateFromBlockTag: (ut) -> + ir = parse "class Foo\n ---does things\n ---@private\n helper: => 1\nreturn Foo" + ut\assertTrue memberByName(ir.classes[1], "helper").isPrivate + + -- ── export unwrapping ─────────────────────────────────────────────────── + + export_returnClass: (ut) -> + ir = parse "class Foo\n go: => 1\nreturn Foo" + ut\assertEquals ir.export.kind, ExportKind.Class + ut\assertEquals ir.export.class.name, "Foo" + + export_implicitTrailingClass: (ut) -> + ir = parse "class Foo\n go: => 1" + ut\assertEquals ir.export.kind, ExportKind.Class + + export_trailingAccessorsInstall: (ut) -> + ir = parse 'Accessors = require "l0.DependencyControl.Accessors"\nclass Foo\n go: => 1\nAccessors.install Foo' + ut\assertEquals ir.export.kind, ExportKind.Class + ut\assertEquals ir.export.name, "Foo" + + export_withTestExportsUnwrapsAndHidesExports: (ut) -> + ir = parse 'UnitTestSuite = require "l0.DependencyControl.UnitTestSuite"\nsecret = -> 1\nclass Foo\n go: => 1\nreturn UnitTestSuite\\withTestExports Foo, {:secret}' + ut\assertEquals ir.export.kind, ExportKind.Class + ut\assertEquals ir.export.name, "Foo" + -- the exports table must not surface anywhere in the export record + ut\assertNil ir.export.fields + + export_registerUnwraps: (ut) -> + ir = parse 'version = {}\nclass Foo\n go: => 1\nreturn version\\register Foo' + ut\assertEquals ir.export.kind, ExportKind.Class + + export_wholeFileTable: (ut) -> + ir = parse '{\n NAME: "depctrl"\n COUNT: 42\n}' + ut\assertEquals ir.export.kind, ExportKind.Table + ut\assertEquals #ir.export.fields, 2 + + export_returnedTableLiteralWithFieldBlocks: (ut) -> + ir = parse '---field doc\n---@param url string\nopenUrl = (url) -> url\nreturn {open: openUrl}' + ut\assertEquals ir.export.kind, ExportKind.Table + ut\assertEquals ir.export.fields[1].name, "open" + ut\assertEquals ir.export.fields[1].valueInfo.kind, ValueKind.Reference + + export_setmetatableTable: (ut) -> + ir = parse 'wrapper = setmetatable {}, {}\nwrapper.encode = (value) -> value\nreturn wrapper' + ut\assertEquals ir.export.kind, ExportKind.Table + ut\assertEquals #ir.augmentations, 1 + ut\assertEquals ir.augmentations[1].name, "encode" + + export_returnFunction: (ut) -> + ir = parse "---resolves hosts\n---@param host string\nresolveHost = (host) -> host\nreturn resolveHost" + ut\assertEquals ir.export.kind, ExportKind.Function + ut\assertEquals ir.export.name, "resolveHost" + ut\assertEquals #ir.export.params, 1 + ut\assertNotNil ir.export.block + + export_unknownReported: (ut) -> + ir, diagnostics = parse "x = 1\nprint x" + ut\assertEquals ir.export.kind, ExportKind.Unknown + found = false + for finding in *diagnostics.findings + found = true if finding.code == Diagnostics.FindingCode.NoExport + ut\assertTrue found + + -- ── enum recognition ──────────────────────────────────────────────────── + + enum_valuesAndTrailingArg: (ut) -> + ir = parse 'Enum = require "l0.DependencyControl.Enum"\nclass Foo\n @logger = {}\n @LockState = Enum "LockState", {\n Unknown: -1\n Free: 0\n Held: "held"\n }, @logger\nreturn Foo' + ut\assertEquals #ir.enums, 1 + enum = ir.enums[1] + ut\assertEquals enum.name, "LockState" + ut\assertEquals enum.exportedAs, "LockState" + ut\assertEquals #enum.members, 3 + ut\assertEquals enum.members[1].literal, "-1" + ut\assertEquals enum.members[3].literal, "\"held\"" + + enum_localThenStaticReExport: (ut) -> + ir = parse 'Enum = require "l0.DependencyControl.Enum"\nclass Foo\n Mode = Enum "Mode", {Fast: "fast"}\n @Mode = Mode\nreturn Foo' + ut\assertEquals #ir.enums, 1 + ut\assertEquals ir.enums[1].exportedAs, "Mode" + member = memberByName ir.classes[1], "Mode" + ut\assertEquals member.kind, MemberKind.EnumExport + + enum_augmentationExport: (ut) -> + ir = parse 'Enum = require "l0.DependencyControl.Enum"\nStatus = Enum "Status", {Ok: 0}\nclass Task\n go: => 1\nTask.Status = Status\nreturn Task' + ut\assertEquals ir.enums[1].exportedAs, "Status" + ut\assertEquals #ir.augmentations, 1 + + enum_computedMembersCounted: (ut) -> + ir = parse 'Enum = require "l0.DependencyControl.Enum"\nkey = "K"\nclass Foo\n @Map = Enum "Map", {[key]: "v", Plain: "p"}\nreturn Foo' + ut\assertEquals #ir.enums[1].members, 1 + ut\assertEquals ir.enums[1].computedKeyCount, 1 + + -- ── explicit-return detection ─────────────────────────────────────────── + + valueReturn_topLevel: (ut) -> + ir = parse "class Foo\n go: => return 1\nreturn Foo" + ut\assertTrue memberByName(ir.classes[1], "go").hasExplicitValueReturn + + valueReturn_insideIf: (ut) -> + ir = parse "class Foo\n go: (x) =>\n if x\n return 5\n x\nreturn Foo" + ut\assertTrue memberByName(ir.classes[1], "go").hasExplicitValueReturn + + valueReturn_implicitNotCounted: (ut) -> + ir = parse "class Foo\n go: (x) => x + 1\nreturn Foo" + ut\assertFalse memberByName(ir.classes[1], "go").hasExplicitValueReturn + + valueReturn_nestedFunctionNotCounted: (ut) -> + ir = parse "class Foo\n go: =>\n helper = -> return 5\n helper\nreturn Foo" + ut\assertFalse memberByName(ir.classes[1], "go").hasExplicitValueReturn + + -- ── parse failure ─────────────────────────────────────────────────────── + + parse_syntaxErrorReturnsNil: (ut) -> + parser = Parser! + ir, err = parser\parse "class {{{", "test.bad" + ut\assertNil ir + ut\assertString err + } diff --git a/modules/l0/MoonCats/test/annotations.moon b/modules/l0/MoonCats/test/annotations.moon new file mode 100644 index 0000000..189cee1 --- /dev/null +++ b/modules/l0/MoonCats/test/annotations.moon @@ -0,0 +1,154 @@ +-- annotations tests: LuaCATS tag-line parsing shared by the definition and doc emitters. +() -> + {:extractTypeText, :parseParamTag, :parseReturnTag, :parseFieldTag, :parseAliasVariants, + :collectBlockTags, :proseLines, :blockTagText, :guardFunType} = require "l0.MoonCats.annotations" + + { + _description: "annotations: LuaCATS tag-line parsing helpers." + + -- ── extractTypeText ───────────────────────────────────────────────────── + + typeText_plainWithDescription: (ut) -> + typeText, rest = extractTypeText "string The name to use." + ut\assertEquals typeText, "string" + ut\assertEquals rest, "The name to use." + + typeText_funWithReturnAndDescription: (ut) -> + ut\assertEquals extractTypeText("fun(key: string, value: any): string A formatter."), + "fun(key: string, value: any): string" + + typeText_funMultiReturnContinuation: (ut) -> + ut\assertEquals extractTypeText("fun(value: any, valueType: string): table?, boolean? A checker."), + "fun(value: any, valueType: string): table?, boolean?" + + typeText_inlineTable: (ut) -> + ut\assertEquals extractTypeText("{ get?: fun(self: any): any, set?: fun(self: any, value: any) } The spec."), + "{ get?: fun(self: any): any, set?: fun(self: any, value: any) }" + + typeText_genericTable: (ut) -> + ut\assertEquals extractTypeText("table<string, [any, string]> Args by name."), + "table<string, [any, string]>" + + -- ── param and return tags ─────────────────────────────────────────────── + + paramTag_optionalDetection: (ut) -> + tag = parseParamTag "---@param join? string|boolean Separator or flag." + ut\assertEquals tag.name, "join" + ut\assertTrue tag.optional + ut\assertEquals tag.type, "string|boolean" + ut\assertEquals tag.description, "Separator or flag." + + returnTag_namedWithDescription: (ut) -> + tag = parseReturnTag "---@return boolean? valid True when the value is a member." + ut\assertEquals tag.type, "boolean?" + ut\assertEquals tag.name, "valid" + ut\assertEquals tag.description, "True when the value is a member." + + returnTag_typeOnly: (ut) -> + tag = parseReturnTag "---@return integer" + ut\assertEquals tag.type, "integer" + ut\assertNil tag.name + ut\assertNil tag.description + + collectTags_paramsAndReturns: (ut) -> + params, returns = collectBlockTags { + "---Does a thing." + "---@param a string The input." + "---@return boolean ok Whether it worked." + "---@return string? err Error message on failure." + } + ut\assertEquals #params, 1 + ut\assertEquals #returns, 2 + ut\assertEquals returns[2].name, "err" + + -- ── field tags ────────────────────────────────────────────────────────── + + fieldTag_plain: (ut) -> + tag = parseFieldTag "---@field semanticVersion SemanticVersion The canonical store." + ut\assertEquals tag.name, "semanticVersion" + ut\assertNil tag.visibility + ut\assertEquals tag.type, "SemanticVersion" + ut\assertEquals tag.description, "The canonical store." + + fieldTag_privateVisibility: (ut) -> + tag = parseFieldTag "---@field private __state table Internal cache." + ut\assertEquals tag.name, "__state" + ut\assertEquals tag.visibility, "private" + ut\assertEquals tag.type, "table" + + fieldTag_optionalComplexTypeNoDescription: (ut) -> + tag = parseFieldTag "---@field limits? table<string, integer>" + ut\assertTrue tag.optional + ut\assertEquals tag.type, "table<string, integer>" + ut\assertNil tag.description + + -- ── alias variants ────────────────────────────────────────────────────── + + aliasVariants_stringValuesWithKeys: (ut) -> + variants = parseAliasVariants { + "---@alias Mode" + '---| "fast" # Fast: quick and shallow' + '---| "slow" # Slow: careful and deep' + } + ut\assertEquals #variants, 2 + ut\assertEquals variants[1].value, '"fast"' + ut\assertEquals variants[1].key, "Fast" + ut\assertEquals variants[1].description, "quick and shallow" + + aliasVariants_numericValues: (ut) -> + variants = parseAliasVariants {"---| -1 # Unknown: state not read yet", "---| 0 # Free: no holder"} + ut\assertEquals variants[1].value, "-1" + ut\assertEquals variants[1].key, "Unknown" + + aliasVariants_plainWithoutComment: (ut) -> + variants = parseAliasVariants {"---| 'major'", "---| 'minor'"} + ut\assertEquals #variants, 2 + ut\assertEquals variants[1].value, "'major'" + ut\assertNil variants[1].key + ut\assertNil variants[1].description + + -- ── prose and generic tags ────────────────────────────────────────────── + + prose_extractsUntaggedLines: (ut) -> + lines = proseLines { + "---First paragraph line." + "---Second line." + "---@param a string" + "---| \"x\" # variant line" + } + ut\assertEquals #lines, 2 + ut\assertEquals lines[1], "First paragraph line." + + prose_preservesIndentAndBlankSeparators: (ut) -> + -- indentation and blank lines carry markdown structure (indented code blocks); + -- an indented @ is example content, not a tag + lines = proseLines { + "---Intro." + "---" + "--- example \"@{fileName}\"" + "---@param a string" + } + ut\assertEquals #lines, 3 + ut\assertEquals lines[2], "" + ut\assertEquals lines[3], " example \"@{fileName}\"" + + prose_trimsBoundaryBlanks: (ut) -> + lines = proseLines {"---", "---Text.", "---"} + ut\assertEquals #lines, 1 + ut\assertEquals lines[1], "Text." + + tagText_deprecatedReason: (ut) -> + reason = blockTagText {"---does things", "---@deprecated Use save instead.", "---@param a string"}, "deprecated" + ut\assertEquals reason, "Use save instead." + + tagText_bareTagYieldsEmpty: (ut) -> + ut\assertEquals blockTagText({"---@private"}, "private"), "" + ut\assertNil blockTagText {"---plain prose"}, "deprecated" + + -- ── guardFunType ──────────────────────────────────────────────────────── + + guard_wrapsFunTypes: (ut) -> + ut\assertEquals guardFunType("fun(x: string): boolean"), "(fun(x: string): boolean)" + ut\assertEquals guardFunType("(fun(x: string): boolean)"), "(fun(x: string): boolean)" + ut\assertEquals guardFunType("string|integer"), "string|integer" + } 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 +<http://dkolf.de/dkjson-lua/>. + +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/config/v0.6.3.json b/schemas/config/v0.6.3.json new file mode 100644 index 0000000..c199d1d --- /dev/null +++ b/schemas/config/v0.6.3.json @@ -0,0 +1,210 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/config/v0.6.3.json", + "title": "DependencyControl Config File v0.6.3 (legacy)", + "description": "The pre-0.7.0 DependencyControl configuration file, whose global settings under `config` use a flat, un-sectioned layout.", + "type": "object", + "additionalProperties": false, + "properties": { + "config": { "$ref": "#/$defs/LegacyGlobalConfig" }, + "macros": { + "description": "Installed automation scripts, keyed by their namespaced identifier.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/NamespacedIdentifier" }, + "additionalProperties": { "$ref": "#/$defs/ScriptRecord" } + }, + "modules": { + "description": "Installed modules, keyed by their namespaced identifier.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/NamespacedIdentifier" }, + "additionalProperties": { "$ref": "#/$defs/ScriptRecord" } + } + }, + "$defs": { + "LegacyGlobalConfig": { + "description": "Global DependencyControl settings in the flat, pre-0.7.0 layout. Unknown keys are tolerated, since older releases stored assorted additional flags here. Each key's v0.7.0 destination is noted in its description.", + "type": "object", + "additionalProperties": true, + "properties": { + "updaterEnabled": { + "description": "Master update switch. Migrates to `updates.mode` ('auto-update' when true, 'off' when false).", + "type": "boolean" + }, + "updateInterval": { + "description": "Minimum seconds between automatic update checks for a given script. Migrates to `updates.checkInterval`.", + "type": "integer", + "minimum": 0 + }, + "updateWaitTimeout": { + "description": "Seconds to wait for another process's in-progress update before giving up. Migrates to `updates.waitTimeout`.", + "type": "integer", + "minimum": 0 + }, + "updateOrphanTimeout": { + "description": "Seconds after which a held updater lock whose owner vanished is treated as stale. Migrates to `updates.orphanTimeout`.", + "type": "integer", + "minimum": 0 + }, + "extraFeeds": { + "description": "Your own feeds, searched for updates and crawled for discovery. Migrates to `feeds.extraFeeds`.", + "type": "array", + "items": { "type": "string", "format": "uri" } + }, + "traceLevel": { + "description": "Default log verbosity; higher levels log more. Migrates to `logging.defaultLevel`.", + "type": "integer" + }, + "writeLogs": { + "description": "Whether to write logs to file. Migrates to `logging.toFile`.", + "type": "boolean" + }, + "logMaxFiles": { + "description": "Maximum number of log files to retain before the oldest are trimmed. Migrates to `logging.maxFiles`.", + "type": "integer", + "minimum": 0 + }, + "logMaxAge": { + "description": "Maximum age, in seconds, of a log file before it is trimmed. Migrates to `logging.maxAge`.", + "type": "integer", + "minimum": 0 + }, + "logMaxSize": { + "description": "Maximum total size, in bytes, of retained log files before trimming. Migrates to `logging.maxSize`.", + "type": "integer", + "minimum": 0 + }, + "configDir": { + "description": "Directory holding DependencyControl's config files (supports Aegisub path tokens). Migrates to `paths.config`.", + "type": "string" + }, + "logDir": { + "description": "Directory holding DependencyControl's log files (supports Aegisub path tokens). Migrates to `paths.log`.", + "type": "string" + }, + "tryAllFeeds": { + "description": "Obsolete: enabled an exhaustive update mode that consulted every known feed. Removed in 0.7.0 in favor of trust-ranked source selection.", + "type": "boolean" + }, + "dumpFeeds": { + "description": "Obsolete: a debug toggle that dumped fetched feeds to disk. Removed in 0.7.0, superseded by the on-disk feed cache.", + "type": "boolean" + }, + "updaterRunning": { + "description": "Obsolete transient flag marking an in-progress update. No longer stored in 0.7.0, where concurrent updates are coordinated by a cross-process lock; a leftover value is harmless.", + "type": "boolean" + } + } + }, + + "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_-]+)+ !." + }, + + "ScriptRecord": { + "description": "The persisted installation record for one managed or unmanaged script/module. Beyond the fields below, DependencyControl may store additional bookkeeping here, so unknown keys are tolerated.", + "type": "object", + "additionalProperties": true, + "properties": { + "namespace": { + "$ref": "#/$defs/NamespacedIdentifier", + "description": "The script's namespaced identifier (matches its key in `macros`/`modules`)." + }, + "name": { + "description": "Human-readable script name.", + "type": "string" + }, + "version": { + "description": "Installed version as DependencyControl's packed version number (a single integer encoding major.minor.patch), not a version string.", + "type": "integer" + }, + "author": { + "description": "Script author.", + "type": "string" + }, + "description": { + "description": "Short description of the script.", + "type": "string" + }, + "feed": { + "description": "The feed this script declares for its own updates.", + "type": "string", + "format": "uri" + }, + "url": { + "description": "The script's homepage or project URL.", + "type": "string", + "format": "uri" + }, + "configFile": { + "description": "Name of this script's own per-script config file.", + "type": "string" + }, + "moduleName": { + "description": "The Lua module name for a module; `false` for an automation script.", + "type": ["string", "boolean"] + }, + "channels": { + "description": "Update channels available for this script.", + "type": "array", + "items": { "type": "string" } + }, + "lastChannel": { + "description": "The update channel last installed from.", + "type": "string" + }, + "requiredModules": { + "description": "The script's declared module dependencies.", + "type": "array", + "items": { "$ref": "#/$defs/ModuleDependency" } + }, + "lastUpdateCheck": { + "description": "Unix time of the last automatic update check for this script.", + "type": "integer" + }, + "unmanaged": { + "description": "True for a script present but installed/managed outside DependencyControl (never auto-updated).", + "type": "boolean" + } + } + }, + + "ModuleDependency": { + "description": "A declared dependency on another module.", + "type": "object", + "required": ["moduleName"], + "additionalProperties": true, + "properties": { + "moduleName": { + "description": "Lua require-path of the dependency — a bare module name ('ffi', 'json', 'Yutils') or a dotted path ('aegisub.util', 'l0.Functional').", + "type": "string" + }, + "version": { + "description": "Minimum required version (a semantic version string, or an npm-style range).", + "type": "string" + }, + "url": { + "description": "The required module's homepage or project URL.", + "type": "string", + "format": "uri" + }, + "feed": { + "description": "A feed to install the required module from.", + "type": "string", + "format": "uri" + }, + "name": { + "description": "Human-readable name of the required module.", + "type": "string" + }, + "optional": { + "description": "When true, DependencyControl skips (rather than fails) if the dependency can't be satisfied.", + "type": "boolean", + "default": false + } + } + } + } +} diff --git a/schemas/config/v0.7.0.json b/schemas/config/v0.7.0.json new file mode 100644 index 0000000..dddd852 --- /dev/null +++ b/schemas/config/v0.7.0.json @@ -0,0 +1,430 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/config/v0.7.0.json", + "title": "DependencyControl Config File v0.7.0", + "description": "DependencyControl's on-disk configuration file (by default `<Aegisub user dir>/config/l0.DependencyControl.json`).", + "type": "object", + "additionalProperties": false, + "required": ["$schema"], + "properties": { + "$schema": { + "description": "Identifies the config format: the URL of the DependencyControl config schema this file conforms to. DependencyControl stamps it when it migrates a pre-0.7.0 config, and editors use it to validate.", + "const": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/config/v0.7.0.json" + }, + "config": { "$ref": "#/$defs/GlobalConfig" }, + "macros": { + "description": "Installed automation scripts, keyed by their namespaced identifier.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/NamespacedIdentifier" }, + "additionalProperties": { "$ref": "#/$defs/ScriptRecord" } + }, + "modules": { + "description": "Installed modules, keyed by their namespaced identifier.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/NamespacedIdentifier" }, + "additionalProperties": { "$ref": "#/$defs/ScriptRecord" } + } + }, + "$defs": { + "GlobalConfig": { + "description": "Global DependencyControl settings, organized into topic sections.", + "type": "object", + "properties": { + "updates": { + "description": "Update-engine settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "$ref": "#/$defs/UpdateContextCeiling", + "description": "Which update contexts may install/update at all: 'auto-update' (default) enables everything, 'dependency-resolution' disables background checks while keeping manual actions and dependency installs, 'user-requested' allows only actions you start yourself, and 'off' disables installs and updates entirely.", + "default": "auto-update" + }, + "checkInterval": { + "description": "Minimum seconds between automatic update checks for a given script.", + "type": "integer", + "minimum": 0, + "default": 302400 + }, + "waitTimeout": { + "description": "Seconds to wait for another process's in-progress update to finish before giving up.", + "type": "integer", + "minimum": 0, + "default": 60 + }, + "orphanTimeout": { + "description": "Seconds after which a held updater lock whose owner has vanished (e.g. a crashed process) is treated as stale and may be taken over.", + "type": "integer", + "minimum": 0, + "default": 50 + }, + "feedTrustPromptThreshold": { + "$ref": "#/$defs/UpdateContextCeiling", + "description": "How freely DependencyControl may prompt to trust an untrusted feed before installing from it. Defaults to prompting in all contexts, since it gates whether an update can succeed; with 'off' (or in a context above the ceiling) the install fails or is skipped instead.", + "default": "auto-update" + }, + "packageChoicePromptThreshold": { + "$ref": "#/$defs/UpdateContextCeiling", + "description": "How freely DependencyControl may prompt to choose among several equally-ranked package sources. Defaults to only actions you start yourself; with 'off' (or in a context above the ceiling) a stable tie-breaker picks instead.", + "default": "user-requested" + }, + "offerAllSources": { + "description": "When more than one source qualifies, offer every eligible one (including lower-ranked and untrusted sources) so you can override the ranking, instead of only the top-ranked candidates.", + "type": "boolean", + "default": false + }, + "blockPrivateHosts": { + "description": "Refuse feed and package downloads from hosts that resolve to a private, loopback, or link-local address (an SSRF safeguard). Turn off to use feeds on your local network.", + "type": "boolean", + "default": true + } + } + }, + "feeds": { + "description": "Feed trust, discovery, and caching settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "extraFeeds": { + "description": "Your own feeds, trusted and used as discovery roots: they are searched for updates and their advertised `knownFeeds` are crawled.", + "type": "array", + "items": { "type": "string", "format": "uri" }, + "default": [] + }, + "trustedFeeds": { + "description": "Feeds you trust but that are not discovery roots: they grant trust to feeds reached another way and are never themselves crawled.", + "type": "array", + "items": { "type": "string", "format": "uri" }, + "default": [] + }, + "blockedFeeds": { + "description": "Feeds that must never be used as a package source, overriding trust. Merged with the official block list in DependencyControl's own feed.", + "type": "array", + "items": { "$ref": "#/$defs/BlockedFeed" }, + "default": [] + }, + "fetchUntrustedFeeds": { + "$ref": "#/$defs/FetchUntrustedFeeds", + "description": "Policy for fetching untrusted feeds encountered during discovery.", + "default": "always" + }, + "crawlLimits": { + "$ref": "#/$defs/CrawlLimits", + "description": "Bounds on untrusted feed-graph expansion during discovery. Any unset budget falls back to its built-in default." + }, + "cacheMaxAge": { + "description": "Default lifetime, in seconds, of a cached feed snapshot before it is refetched.", + "type": "integer", + "minimum": 0, + "default": 3600 + } + } + }, + "logging": { + "description": "Logging and log-retention settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "defaultLevel": { + "description": "Default log verbosity; higher levels log more (mirrors the Logger's `defaultLevel`).", + "type": "integer", + "default": 3 + }, + "toFile": { + "description": "Whether to write logs to file (mirrors the Logger's `toFile`).", + "type": "boolean", + "default": true + }, + "maxFiles": { + "description": "Maximum number of log files to retain before the oldest are trimmed.", + "type": "integer", + "minimum": 0, + "default": 200 + }, + "maxAge": { + "description": "Maximum age, in seconds, of a log file before it is trimmed.", + "type": "integer", + "minimum": 0, + "default": 604800 + }, + "maxSize": { + "description": "Maximum total size, in bytes, of retained log files before trimming.", + "type": "integer", + "minimum": 0, + "default": 10000000 + } + } + }, + "paths": { + "description": "Base directories for DependencyControl's files. Each supports Aegisub path tokens such as `?user` and `?data`.", + "type": "object", + "additionalProperties": false, + "properties": { + "config": { + "description": "Directory holding DependencyControl's config files.", + "type": "string", + "default": "?user/config" + }, + "log": { + "description": "Directory holding DependencyControl's log files.", + "type": "string", + "default": "?user/log" + }, + "cache": { + "description": "Base directory for DependencyControl's on-disk caches. Each cache lives in a `<namespace>/<name>` subdirectory beneath it (e.g. the feed cache at `<cache>/l0.DependencyControl/feeds`).", + "type": "string", + "default": "?user/cache" + } + } + } + } + }, + + "UpdateContextCeiling": { + "description": "A ceiling on the ladder of update contexts, naming the most autonomous context for which the gated behavior still applies. Each value includes all less autonomous contexts: 'off' (none at all) < 'user-requested' (actions you start yourself, e.g. via the Toolbox) < 'dependency-resolution' (also installing/updating a module as a dependency) < 'auto-update' (also background scheduled update checks).", + "enum": ["off", "user-requested", "dependency-resolution", "auto-update"] + }, + + "FetchUntrustedFeeds": { + "description": "Policy for fetching an untrusted feed: 'always' fetches without asking; 'never' skips it; 'prompt' asks first, falling back to 'never' where no prompter is available (e.g. headless).", + "enum": ["always", "never", "prompt"] + }, + + "CrawlLimits": { + "description": "Per-budget caps on untrusted feed-graph expansion during discovery, keyed by budget name.", + "type": "object", + "additionalProperties": false, + "properties": { + "per-feed": { + "description": "Cap on how many untrusted feeds a single feed may contribute.", + "type": "integer", + "minimum": 0, + "default": 25 + }, + "per-root": { + "description": "Per-subtree budget for untrusted expansion out from one config-derived feed.", + "type": "integer", + "minimum": 0, + "default": 50 + }, + "depth": { + "description": "Crawl-depth limit; a feed reached at this depth is left unfetched.", + "type": "integer", + "minimum": 0, + "default": 7 + } + } + }, + + "BlockedFeed": { + "description": "A blocked feed entry: the URL/prefix to match, how to match it, and why.", + "type": "object", + "required": ["url"], + "additionalProperties": false, + "properties": { + "url": { + "description": "The feed URL or URL prefix to match (case-insensitive).", + "type": "string" + }, + "matchMode": { + "description": "How `url` is matched: 'prefix' (default) blocks every feed whose URL starts with `url`; 'exact' blocks only that exact URL.", + "enum": ["prefix", "exact"], + "default": "prefix" + }, + "reason": { + "description": "Human-readable explanation of why the feed is blocked.", + "type": "string" + } + } + }, + + "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_-]+)+ !." + }, + + "ScriptRecord": { + "description": "The persisted installation record for one managed or unmanaged script/module. Beyond the fields below, DependencyControl may store additional bookkeeping here, so unknown keys are tolerated.", + "type": "object", + "additionalProperties": true, + "properties": { + "namespace": { + "$ref": "#/$defs/NamespacedIdentifier", + "description": "The script's namespaced identifier (matches its key in `macros`/`modules`)." + }, + "name": { + "description": "Human-readable script name.", + "type": "string" + }, + "version": { + "description": "Installed version as a semantic version string (major.minor.patch).", + "type": "string" + }, + "author": { + "description": "Script author.", + "type": "string" + }, + "description": { + "description": "Short description of the script.", + "type": "string" + }, + "feed": { + "description": "The feed this script declares for its own updates.", + "type": "string", + "format": "uri" + }, + "userFeed": { + "description": "A per-script feed override you set, taking precedence over the declared `feed`.", + "type": "string", + "format": "uri" + }, + "url": { + "description": "The script's homepage or project URL.", + "type": "string", + "format": "uri" + }, + "configFile": { + "description": "Name of this script's own per-script config file.", + "type": "string" + }, + "moduleName": { + "description": "The Lua module name for a module; `false` for an automation script.", + "type": ["string", "boolean"] + }, + "channels": { + "description": "Update channels available for this script.", + "type": "array", + "items": { "type": "string" } + }, + "lastChannel": { + "description": "The update channel last installed from.", + "type": "string" + }, + "requiredModules": { + "description": "The script's declared module dependencies.", + "type": "array", + "items": { "$ref": "#/$defs/ModuleDependency" } + }, + "provides": { + "description": "Module aliases this module satisfies for `require` (a bare string is shorthand for `{ \"name\": <string> }`).", + "type": "array", + "items": { "$ref": "#/$defs/ModuleAlias" } + }, + "lastUpdateCheck": { + "description": "Unix time of the last automatic update check for this script.", + "type": "integer" + }, + "currentSource": { + "$ref": "#/$defs/SourceChoiceRecord", + "description": "The remembered source this script effectively updates from, and how sticky that choice is." + }, + "unmanaged": { + "description": "True for a script present but installed/managed outside DependencyControl (never auto-updated).", + "type": "boolean" + } + } + }, + + "ModuleDependency": { + "description": "A declared dependency on another module.", + "type": "object", + "required": ["moduleName"], + "additionalProperties": true, + "properties": { + "moduleName": { + "description": "Lua require-path of the dependency — a bare module name ('ffi', 'json', 'Yutils') or a dotted path ('aegisub.util', 'l0.Functional').", + "type": "string" + }, + "version": { + "description": "Minimum required version (a semantic version string, or an npm-style range).", + "type": "string" + }, + "url": { + "description": "The required module's homepage or project URL.", + "type": "string", + "format": "uri" + }, + "feed": { + "description": "A feed to install the required module from.", + "type": "string", + "format": "uri" + }, + "name": { + "description": "Human-readable name of the required module.", + "type": "string" + }, + "optional": { + "description": "When true, DependencyControl skips (rather than fails) if the dependency can't be satisfied.", + "type": "boolean", + "default": false + } + } + }, + + "ModuleAlias": { + "description": "An alias a module provides for `require`. Either a bare alias name, or an object naming the alias and (optionally) the version range it satisfies.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "description": "The provided alias name.", + "type": "string" + }, + "version": { + "description": "The version (or npm-style range) the alias satisfies. Omit for any version.", + "type": "string" + } + } + } + ] + }, + + "SourceChoiceRecord": { + "description": "A remembered package-source choice, persisted so DependencyControl keeps using and updating the same source.", + "type": "object", + "required": ["feedSource", "channel", "stickiness"], + "additionalProperties": false, + "properties": { + "feedSource": { + "description": "Where the source came from: 'self-declared' (the record's own feed), 'user-feed' (a per-script override), 'provider' (another module that provides the required one), or 'other' (a third-party trusted/extra feed, whose URL is stored in `feedUrl`).", + "enum": ["self-declared", "user-feed", "provider", "other"] + }, + "feedUrl": { + "description": "The literal feed URL; stored (and required) only when `feedSource` is 'other'.", + "type": "string", + "format": "uri" + }, + "channel": { + "description": "The update channel the source was resolved on.", + "type": "string" + }, + "provider": { + "description": "The provider that satisfied the requirement, when resolved indirectly through a `provides` alias.", + "type": "object", + "required": ["namespace"], + "additionalProperties": false, + "properties": { + "namespace": { + "$ref": "#/$defs/NamespacedIdentifier", + "description": "Namespace of the providing module." + }, + "version": { + "description": "The provided alias version range, if the provider declared one.", + "type": "string" + } + } + }, + "stickiness": { + "description": "How sticky the choice is on later resolutions: 'unset' (resolve normally, prompt only if interactive); 'once' (prompt again but preselect this pick); 'retain' (reuse without prompting while still eligible); 'pinned' (always reuse; fail a required dependency if it's gone, skip an optional one); 'auto' (never prompt, always re-resolve by ranking).", + "enum": ["unset", "once", "retain", "pinned", "auto"] + } + } + } + } +} diff --git a/schemas/feed/v0.4.0.json b/schemas/feed/v0.4.0.json new file mode 100644 index 0000000..4673202 --- /dev/null +++ b/schemas/feed/v0.4.0.json @@ -0,0 +1,400 @@ +{ + "$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 local-mode path resolution (localFileBasePath/localFilePath) for CI/CLI tooling, the per-file-type fileBaseUrls/localFileBasePaths template maps, author-defined template variables (vars) with computed-key lookups, the scriptType/scriptTypeSection variables, section-scoped rolling template overrides, Release.provides for fallback package resolution, and blockedFeeds." + }, + "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 until overridden, and serves as the fallback for file types without a fileBaseUrls entry." + }, + "fileBaseUrls": { + "$ref": "#/$defs/FileTypeTemplateMap", + "description": "Per-file-type map of full download URL templates. At each file record, the entry matching the file's type becomes the effective @{fileBaseUrl} value." + }, + "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": "./" + }, + "localFileBasePaths": { + "$ref": "#/$defs/FileTypeTemplateMap", + "description": "Local-mode counterpart of `fileBaseUrls`: per-file-type map of full on-disk path templates, resolved relative to the feed file." + }, + "vars": { + "$ref": "#/$defs/Vars" + }, + "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" + } + }, + "blockedFeeds": { + "description": "Feeds that must never be used as a package source. Only honored in DependencyControl's own feed, where it acts as an official block list (a last line of defense against compromised feeds). Each entry specifies a URL (or, for a 'prefix' block, a URL prefix like 'https://example.com/' that blocks every feed under it), how to match it, and optionally why. Added in v0.4.0.", + "type": "array", + "items": { "$ref": "#/$defs/BlockedFeed" } + }, + "macros": { + "$ref": "#/$defs/PackageSection", + "description": "Aegisub automation scripts keyed by their namespaced identifiers." + }, + + "modules": { + "$ref": "#/$defs/PackageSection", + "description": "Lua module entries keyed by their namespaced identifiers." + } + }, + "$defs": { + "BlockedFeed": { + "description": "A blocked feed entry: the URL/prefix to match, how to match it, and why.", + "type": "object", + "required": ["url"], + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "The feed URL or URL prefix to match (case-insensitive)." + }, + "matchMode": { + "description": "How `url` is matched: 'prefix' (default) blocks every feed whose URL starts with `url`; 'exact' blocks only that exact URL.", + "enum": ["prefix", "exact"], + "default": "prefix" + }, + "reason": { + "type": "string", + "description": "Human-readable explanation of why the feed is blocked." + } + } + }, + + "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 (<major>.<minor>.<patch>).", + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "lpegPattern": "%d+ '.' %d+ '.' %d+ !." + }, + + "SemanticVersionRangeWithoutLabels": { + "description": "An npm-style version range over semantic versions *without* pre-release or build metadata (see https://docs.npmjs.com/cli/v6/using-npm/semver#ranges).", + "type": "string", + "pattern": "^\\s*(?:(?:\\d+|[xX*])(?:\\.(?:\\d+|[xX*])){0,2}\\s+-\\s+(?:\\d+|[xX*])(?:\\.(?:\\d+|[xX*])){0,2}|(?:~>?|\\^|>=|<=|>|<|=)?(?:\\d+|[xX*])(?:\\.(?:\\d+|[xX*])){0,2}(?:\\s+(?:~>?|\\^|>=|<=|>|<|=)?(?:\\d+|[xX*])(?:\\.(?:\\d+|[xX*])){0,2})*)(?:\\s*\\|\\|\\s*(?:(?:\\d+|[xX*])(?:\\.(?:\\d+|[xX*])){0,2}\\s+-\\s+(?:\\d+|[xX*])(?:\\.(?:\\d+|[xX*])){0,2}|(?:~>?|\\^|>=|<=|>|<|=)?(?:\\d+|[xX*])(?:\\.(?:\\d+|[xX*])){0,2}(?:\\s+(?:~>?|\\^|>=|<=|>|<|=)?(?:\\d+|[xX*])(?:\\.(?:\\d+|[xX*])){0,2})*))*\\s*$", + "lpegPattern": "%s* ((%d+ / [xX*]) ('.' (%d+ / [xX*]))? ('.' (%d+ / [xX*]))? %s+ '-' %s+ (%d+ / [xX*]) ('.' (%d+ / [xX*]))? ('.' (%d+ / [xX*]))? / ('~>' / '~' / '^' / '>=' / '<=' / '>' / '<' / '=')? (%d+ / [xX*]) ('.' (%d+ / [xX*]))? ('.' (%d+ / [xX*]))? (%s+ ('~>' / '~' / '^' / '>=' / '<=' / '>' / '<' / '=')? (%d+ / [xX*]) ('.' (%d+ / [xX*]))? ('.' (%d+ / [xX*]))?)*) (%s* '||' %s* ((%d+ / [xX*]) ('.' (%d+ / [xX*]))? ('.' (%d+ / [xX*]))? %s+ '-' %s+ (%d+ / [xX*]) ('.' (%d+ / [xX*]))? ('.' (%d+ / [xX*]))? / ('~>' / '~' / '^' / '>=' / '<=' / '>' / '<' / '=')? (%d+ / [xX*]) ('.' (%d+ / [xX*]))? ('.' (%d+ / [xX*]))? (%s+ ('~>' / '~' / '^' / '>=' / '<=' / '>' / '<' / '=')? (%d+ / [xX*]) ('.' (%d+ / [xX*]))? ('.' (%d+ / [xX*]))?)*))* %s* !.", + "examples": ["1.2.3", ">=1.2.7 <1.3.0", "1.x", "~1.2.3", "^1.2.3", "1.2 - 2.3", ">=1.2.3 <2.0 || >=2.1"] + }, + + "TemplateString": { + "description": "A string that may contain `@{variableName}` or `@{lookupName:key}` template substitutions, expanded before the value is used. Built-in variables: `feedName`, `baseUrl`, `scriptType`, `scriptTypeSection`, `namespace`, `namespacePath`, `scriptName`, `channel`, `version`, `platform`, `fileName`, plus the rolling `fileBaseUrl`/`localFileBasePath` values and any author-defined `vars` names. The two-part form indexes a lookup table: `@{feed:alias}` reads knownFeeds and `@{name:key}` reads a table-valued `vars` entry; the key part may itself be a variable (e.g. `@{tagSuffix:@{channel}}`), resolving once the inner variable is in scope. A variable that is not in scope where it appears is left in place and expands at a deeper level where it gains a value.", + "type": "string" + }, + + "FileTypeTemplateMap": { + "description": "A per-file-type map of full URL/path templates, usually ending in @{fileName}. At each file record, the entry matching the file's `type` ('script' when the field is absent) replaces the effective scalar @{fileBaseUrl}/@{localFileBasePath} value; a type with no entry keeps the scalar fallback. Rolling: may be set at the feed root, a section container, a package, or a channel, and propagates down until overridden. Added in v0.4.0.", + "type": "object", + "properties": { + "script": { "$ref": "#/$defs/TemplateString" }, + "test": { "$ref": "#/$defs/TemplateString" } + }, + "additionalProperties": false + }, + + "Vars": { + "description": "Author-defined template variables. Each key becomes a @{name} variable available throughout the feed; a string value is substituted directly, while an object value defines a lookup table used through the computed-key form @{name:key} (e.g. @{tagSuffix:@{channel}}). Built-in variable names are reserved and ignored here. Added in v0.4.0.", + "type": "object", + "additionalProperties": { + "oneOf": [ + { "$ref": "#/$defs/TemplateString" }, + { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/TemplateString" } + } + ] + } + }, + + "PackageSection": { + "description": "A map of packages keyed by their namespaced identifiers. The bare (dot-free) property names are reserved for rolling template values scoped to this section; a valid namespace always contains a dot, so the two can never collide.", + "type": "object", + "properties": { + "fileBaseUrl": { "$ref": "#/$defs/TemplateString", "description": "Section-scoped override of the rolling @{fileBaseUrl} value." }, + "fileBaseUrls": { "$ref": "#/$defs/FileTypeTemplateMap", "description": "Section-scoped override of the per-file-type URL templates." }, + "localFileBasePath": { "$ref": "#/$defs/TemplateString", "description": "Section-scoped override of the rolling @{localFileBasePath} value." }, + "localFileBasePaths": { "$ref": "#/$defs/FileTypeTemplateMap", "description": "Section-scoped override of the per-file-type local path templates." } + }, + "additionalProperties": { "$ref": "#/$defs/Package" }, + "propertyNames": { + "oneOf": [ + { "$ref": "#/$defs/NamespacedIdentifier" }, + { "enum": ["fileBaseUrl", "fileBaseUrls", "localFileBasePath", "localFileBasePaths"] } + ] + } + }, + + "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": "The file's path within its package, appended to the package's install directory on deployment. Conventionally starts with '.' for the primary 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.", + "type": "string" + }, + "url": { + "$ref": "#/$defs/TemplateString", + "description": "Absolute download URL after template expansion. With a fileBaseUrls map in effect this is simply `@{fileBaseUrl}`; with a scalar base the conventional form is `@{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. When omitted, it is resolved from the effective local path template: a collapsed localFileBasePaths entry is used as-is, while a scalar localFileBasePath gets the file name appended. Added in v0.4.0" + }, + "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 + } + } + }, + + "ModuleAlias": { + "description": "A module alias declared in a release's `provides`. Either a bare alias string (shorthand for an object with just `name`), or an object carrying the alias `name` and optional metadata.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "description": "The provided module alias: a `require` path that this module can satisfy. May be bare/non-namespaced (e.g. 'json') even though the providing module's own identifier is namespaced.", + "type": "string" + }, + "version": { + "$ref": "#/$defs/SemanticVersionRangeWithoutLabels", + "description": "The version (or npm-style version range) of the provided module that this provider satisfies. A range lets a provider stay compatible across patch/minor bumps of the provided module without being re-published. Omitting this field indicates that all versions are supported." + } + } + } + ] + }, + + "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 for this channel's file downloads. 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." + }, + "fileBaseUrls": { + "$ref": "#/$defs/FileTypeTemplateMap", + "description": "Channel-scoped override of the per-file-type URL templates." + }, + "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" + }, + "localFileBasePaths": { + "$ref": "#/$defs/FileTypeTemplateMap", + "description": "Channel-scoped override of the per-file-type local path templates." + }, + "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" } + }, + "provides": { + "description": "Module aliases this release can satisfy for `require` (cf. Debian `Provides:` / Arch `provides`). When a script's requiredModules names one of these aliases and no feed offers it directly, DependencyControl may install this module to satisfy the dependency. Version satisfaction uses this module's own release version.", + "type": "array", + "items": { "$ref": "#/$defs/ModuleAlias" } + }, + "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." + }, + "fileBaseUrls": { + "$ref": "#/$defs/FileTypeTemplateMap", + "description": "Package-scoped override of the per-file-type URL templates." + }, + "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" + }, + "localFileBasePaths": { + "$ref": "#/$defs/FileTypeTemplateMap", + "description": "Package-scoped override of the per-file-type local path templates." + }, + "channels": { + "description": "Available release channels keyed by channel name.", + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/Release" } + }, + "changelog": { + "$ref": "#/$defs/Changelog" + } + } + } + } +}