From 24254cacad7dc28972cb2a9cd7c0cd7dd028b5c8 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 2 Aug 2026 11:57:18 +0200 Subject: [PATCH 1/7] feat(paths): follow the XDG base directory spec via the ?state path token Aegisub's XDG support (arch1t3cht/Aegisub#160) adds a ?state path token and moves its own logs to ?state/log. Point DepCtrl's log directory there and its cache at ?local/cache, the XDG cache home, leaving config on ?user. The new path-ops module probes each token once and substitutes a fallback for one the running build can't resolve, so ?state degrades to ?user on an Aegisub that predates it and a path setting naming ?state works on every version. Decoding for logs, caches, package configs and every FileOps path now routes through it. The headless shim gains ?state (DEPCTRL_STATE_DIR), defaulting to ?user as Aegisub does on Windows and macOS. Co-Authored-By: Claude Opus 5 (1M context) --- DependencyControl.json | 19 +++-- README.md | 8 +- depctrl.lua | 4 +- macros/l0.DependencyControl.Toolbox.moon | 4 +- modules/l0/AegisubShims/aegisub.moon | 3 + modules/l0/DependencyControl.moon | 4 +- modules/l0/DependencyControl/FileCache.moon | 7 +- modules/l0/DependencyControl/Logger.moon | 10 ++- .../l0/DependencyControl/PackageRecord.moon | 5 +- .../l0/DependencyControl/config-schema.moon | 4 +- modules/l0/DependencyControl/file-ops.moon | 3 +- modules/l0/DependencyControl/path-ops.moon | 43 +++++++++++ modules/l0/DependencyControl/test.moon | 1 + .../DependencyControl/test/config-schema.moon | 7 +- .../l0/DependencyControl/test/path-ops.moon | 77 +++++++++++++++++++ schemas/config/v0.7.0.json | 6 +- 16 files changed, 174 insertions(+), 31 deletions(-) create mode 100644 modules/l0/DependencyControl/path-ops.moon create mode 100644 modules/l0/DependencyControl/test/path-ops.moon diff --git a/DependencyControl.json b/DependencyControl.json index bec095d..aeec769 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -55,14 +55,14 @@ "url": "@{baseUrl}#@{namespace}", "channels": { "main": { - "version": "0.8.1", - "released": "2026-07-24", + "version": "0.9.0", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "F7B62EAC79C4FBA001B50C12506231C204CC7E80" + "sha1": "E6C82B7325F2191288BE3A25BA9EDA9BE90436E1" }, { "name": ".moon", @@ -109,6 +109,9 @@ ], "0.8.1": [ "fix: The Install Script macro no longer fails while scanning feeds that set URL templates per section, DependencyControl's own feed among them." + ], + "0.9.0": [ + "change: The Global Configuration dialog now notes that the log directory's `?state` token falls back to `?user` on an Aegisub build without it." ] } } @@ -121,14 +124,14 @@ "url": "@{baseUrl}#@{namespace}", "channels": { "main": { - "version": "0.8.1", - "released": "2026-07-24", + "version": "0.9.0", + "released": null, "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "87CDA007933A971BAEFC91A044CF7FC941535D0D" + "sha1": "9AA2EBC0ADC9B263C9895962CE0E351D010C80B8" }, { "name": "/Constants.moon", @@ -779,6 +782,10 @@ ], "0.8.1": [ "fix: Reading the channel list of a package that declares no channels returns an empty list instead of throwing an error." + ], + "0.9.0": [ + "change: Log files and on-disk caches now follow the XDG Base Directory specification, moving to `?state/log` and `?local/cache`. An Aegisub build that lacks the newer `?state` path token keeps writing logs to `?user/log`, so a path setting naming `?state` works on every version.", + "feat: Added `DependencyControl.PathOps` for resolving Aegisub path tokens with a fallback for tokens the running Aegisub build doesn't know." ] } }, diff --git a/README.md b/README.md index 8cf5705..ea9cf4a 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,13 @@ Settings in the top-level `config` object affect all scripts and DependencyContr - _int_ **maxAge [604800 (1 week)]:** Delete log files whose last-modified date is older than this many seconds. - _int_ **maxSize [10000000 (10 MB)]:** Cumulative byte-size limit across all log files. -**`paths` — base directories** (each accepts Aegisub path tokens such as `?user` and `?data`) +**`paths` — base directories** (each accepts Aegisub path tokens such as `?user`, `?local`, `?state` and `?data`) - _str_ **config ["?user/config"]:** Directory for DependencyControl's config files; also the directory offered to automation scripts for their own config (they may or may not use it). -- _str_ **log ["?user/log"]:** Directory for DependencyControl's log files. -- _str_ **cache ["?user/cache"]:** Base directory for on-disk caches. Each cache lives under a `/` subdirectory (e.g. the feed cache at `/l0.DependencyControl/feeds`). +- _str_ **log ["?state/log"]:** Directory for DependencyControl's log files. +- _str_ **cache ["?local/cache"]:** Base directory for on-disk caches. Each cache lives under a `/` subdirectory (e.g. the feed cache at `/l0.DependencyControl/feeds`). + +These defaults follow the [XDG Base Directory specification](https://specifications.freedesktop.org/basedir-spec/latest/) on Aegisub builds that implement it: settings land in the config directory, disposable caches in the cache directory, and logs in the state directory. `?state` is newer than `?user`, `?local` and `?data`, so DependencyControl resolves it to `?user` on an Aegisub build that doesn't know it — a path setting naming `?state` works on every version. #### 2. Per-script Configuration diff --git a/depctrl.lua b/depctrl.lua index 9b4ea4d..1b8826c 100644 --- a/depctrl.lua +++ b/depctrl.lua @@ -195,13 +195,13 @@ local aegisub = shims.aegisub -- pulled into local scope; global is set by the s 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 + for _, token in ipairs({ "user", "local", "data", "state", "temp" }) do shims.setPathToken(token, workspace .. pathSep .. token) end local FileOps = require "l0.DependencyControl.file-ops" FileOps.mkdir("?temp", false, true) - FileOps.mkdir("?user/log", false, true) + FileOps.mkdir("?state/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). diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index 4c9bf38..900511d 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -1,6 +1,6 @@ export script_name = "DependencyControl Toolbox" export script_description = "Provides DependencyControl maintenance and configuration tools." -export script_version = "0.8.1" -- @{l0.DependencyControl.Toolbox:version} +export script_version = "0.9.0" -- @{l0.DependencyControl.Toolbox:version} export script_author = "line0" export script_namespace = "l0.DependencyControl.Toolbox" @@ -668,7 +668,7 @@ configFields = { {section: "paths", key: "config", type: "string", default: sections.paths.config, label: "Config directory", hint: "Where DependencyControl keeps its config files. Aegisub path tokens like ?user are allowed."} {section: "paths", key: "log", type: "string", default: sections.paths.log, - label: "Log directory", hint: "Where DependencyControl writes its log files."} + label: "Log directory", hint: "Where DependencyControl writes its log files. ?state falls back to ?user on an Aegisub that doesn't have it."} {section: "paths", key: "cache", type: "string", default: sections.paths.cache, label: "Cache directory", hint: "Base directory for on-disk caches such as the feed cache."} } diff --git a/modules/l0/AegisubShims/aegisub.moon b/modules/l0/AegisubShims/aegisub.moon index f016713..c9efd0e 100644 --- a/modules/l0/AegisubShims/aegisub.moon +++ b/modules/l0/AegisubShims/aegisub.moon @@ -4,6 +4,7 @@ -- 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_STATE_DIR — base for ?state (default: same as ?user) -- DEPCTRL_TEMP_DIR — base for ?temp (default: %TEMP% / /tmp) ffi = require "ffi" @@ -16,6 +17,7 @@ tempDir = os.getenv("DEPCTRL_TEMP_DIR") or (isWindows and (os.getenv("TEMP")) or 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 +stateDir = os.getenv("DEPCTRL_STATE_DIR") or userDir userPathsAddedToPackagePathLua = {} userPathsAddedToPackagePathMoon = {} @@ -31,6 +33,7 @@ pathTokens = { "?dictionary": dataDir .. pathSep .. "dictionaries" "?local": userDir "?script": "" + "?state": stateDir "?temp": tempDir "?user": userDir "?video": "" diff --git a/modules/l0/DependencyControl.moon b/modules/l0/DependencyControl.moon index 2b5d2a8..a487779 100644 --- a/modules/l0/DependencyControl.moon +++ b/modules/l0/DependencyControl.moon @@ -63,6 +63,7 @@ Host = require "l0.DependencyControl.Host" Lock = require "l0.DependencyControl.Lock" Logger = require "l0.DependencyControl.Logger" PackageRecord = require "l0.DependencyControl.PackageRecord" +pathOps = require "l0.DependencyControl.path-ops" Accessors = require "l0.DependencyControl.Accessors" Stub = require "l0.DependencyControl.Stub" Timer = require "l0.DependencyControl.Timer" @@ -94,6 +95,7 @@ class DependencyControl extends PackageRecord @Lock = Lock @Logger = Logger @PackageRecord = PackageRecord + @PathOps = pathOps @Stub = Stub @Timer = Timer @UpdateFeed = UpdateFeed @@ -108,7 +110,7 @@ Accessors.install DependencyControl rec = DependencyControl{ name: "DependencyControl", - version: "0.8.1", -- @{l0.DependencyControl:version} + version: "0.9.0", -- @{l0.DependencyControl:version} description: "Provides script management and auto-updating for Aegisub macros and modules.", author: "line0", url: "http://github.com/TypesettingTools/DependencyControl", diff --git a/modules/l0/DependencyControl/FileCache.moon b/modules/l0/DependencyControl/FileCache.moon index 26e32fa..6e2c9dc 100644 --- a/modules/l0/DependencyControl/FileCache.moon +++ b/modules/l0/DependencyControl/FileCache.moon @@ -3,6 +3,7 @@ fileOps = require "l0.DependencyControl.file-ops" Logger = require "l0.DependencyControl.Logger" constants = require "l0.DependencyControl.Constants" Lock = require "l0.DependencyControl.Lock" +pathOps = require "l0.DependencyControl.path-ops" dkjson = require "l0.dkjson" defaultLogger = Logger fileBaseName: "#{constants.DEPCTRL_SHORT_NAME}.FileCache" @@ -25,7 +26,7 @@ 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}" +resolveDir = (basePath, namespace, name) -> "#{pathOps.decode basePath}/#{namespace}/#{name}" ---The per-key index entry FileCache persists next to each snapshot; returned by getMeta/getFile/get/put. ---@class FileCacheMeta @@ -61,7 +62,7 @@ class FileCache ---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 basePath string The cache root (the `paths.cache` setting, e.g. "?local/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. @@ -74,7 +75,7 @@ class FileCache FileCache.__instances[dir] = cache return cache - ---@param basePath string The cache root (the `paths.cache` setting, e.g. "?user/cache"); path-decoded here. + ---@param basePath string The cache root (the `paths.cache` setting, e.g. "?local/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. diff --git a/modules/l0/DependencyControl/Logger.moon b/modules/l0/DependencyControl/Logger.moon index e8bb802..40b61d6 100644 --- a/modules/l0/DependencyControl/Logger.moon +++ b/modules/l0/DependencyControl/Logger.moon @@ -1,4 +1,5 @@ lfs = require "lfs" +pathOps = require "l0.DependencyControl.path-ops" utils = require "l0.DependencyControl.utils" ---Structured logger that writes to Aegisub's log window and optional log files. @@ -9,7 +10,7 @@ class Logger maxToFileLevel: 5 fileBaseName: script_namespace or "UNKNOWN" fileSubName: "" - logDir: "?user/log" + logDir: "?state/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: "" @@ -42,7 +43,7 @@ class Logger @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"), + @fileName = @fileTemplate\format pathOps.decode(@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. @@ -68,7 +69,8 @@ class Logger 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) + -- one level only, so this relies on the log dir's parent already existing + lfs.mkdir pathOps.decode @logDir @handle = io.open @fileName, "a" unless @handle -- missing dir / permissions / disk full: disable file logging rather than crash on every @@ -269,7 +271,7 @@ class Logger trimFiles: (doWipe, maxAge = @maxAge, maxSize = @maxSize, maxFiles = @maxFiles) => files, totalSize, deletedSize, now, f = {}, 0, 0, os.time!, 0 - dir = aegisub.decode_path @logDir + dir = pathOps.decode @logDir -- nothing to trim if the log directory hasn't been created yet return 0, 0, 0, 0 unless lfs.attributes dir, "mode" diff --git a/modules/l0/DependencyControl/PackageRecord.moon b/modules/l0/DependencyControl/PackageRecord.moon index 4658d5a..9a88729 100644 --- a/modules/l0/DependencyControl/PackageRecord.moon +++ b/modules/l0/DependencyControl/PackageRecord.moon @@ -10,6 +10,7 @@ fileOps = require "l0.DependencyControl.file-ops" Updater = require "l0.DependencyControl.Updater" ModuleLoader = require "l0.DependencyControl.ModuleLoader" ModuleProvider = require "l0.DependencyControl.ModuleProvider" +pathOps = require "l0.DependencyControl.path-ops" SemanticVersion = require "l0.DependencyControl.SemanticVersion" Accessors = require "l0.DependencyControl.Accessors" UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" @@ -142,7 +143,7 @@ class PackageRecord @updater = Updater script_namespace, @config, @logger @configDir = paths.config - fileOps.mkdir aegisub.decode_path @configDir + fileOps.mkdir pathOps.decode @configDir @logger\trimFiles! fileOps.runScheduledRemoval @configDir @@ -328,7 +329,7 @@ class PackageRecord ---they stay discoverable to other scripts through the DependencyControl config file. ---@return string path getConfigFileName: () => - return aegisub.decode_path "#{@@configDir}/#{@configFile}" + return pathOps.decode "#{@@configDir}/#{@configFile}" ---Creates a ConfigView for this record's script-specific config file. ---@param defaults? table Default values for the config. diff --git a/modules/l0/DependencyControl/config-schema.moon b/modules/l0/DependencyControl/config-schema.moon index 061043d..fb03ed3 100644 --- a/modules/l0/DependencyControl/config-schema.moon +++ b/modules/l0/DependencyControl/config-schema.moon @@ -23,8 +23,8 @@ sections = { } paths: { config: "?user/config" - log: "?user/log" - cache: "?user/cache" + log: "?state/log" + cache: "?local/cache" } } diff --git a/modules/l0/DependencyControl/file-ops.moon b/modules/l0/DependencyControl/file-ops.moon index 8170188..f91691a 100644 --- a/modules/l0/DependencyControl/file-ops.moon +++ b/modules/l0/DependencyControl/file-ops.moon @@ -3,6 +3,7 @@ lfs = require "lfs" constants = require "l0.DependencyControl.Constants" Logger = require "l0.DependencyControl.Logger" domain = require "l0.DependencyControl.domain" +pathOps = require "l0.DependencyControl.path-ops" utils = require "l0.DependencyControl.utils" Hash = require "l0.DependencyControl.hash" @@ -701,7 +702,7 @@ FileOps = { return nil, msgs.validateFullPath.badType\format 3, "basePath", type(basePath) -- expand aegisub path specifiers - path = aegisub.decode_path path + path = pathOps.decode path -- expand home directory on linux homeDir = os.getenv "HOME" path = path\gsub "^~", "#{homeDir}/" if homeDir diff --git a/modules/l0/DependencyControl/path-ops.moon b/modules/l0/DependencyControl/path-ops.moon new file mode 100644 index 0000000..664677f --- /dev/null +++ b/modules/l0/DependencyControl/path-ops.moon @@ -0,0 +1,43 @@ +---Stand-in token for each token only newer Aegisub builds resolve, keyed by the newer token. +fallbacks = { + "?state": "?user" +} + +-- forward-declared so the members below close over the local rather than a global of the same name +local PathOps + +---Decodes Aegisub path tokens, substituting a fallback for a token the running build can't resolve. +---`?state` falls back to `?user`, so a path naming it works on an Aegisub that predates the token. +---@class PathOps +PathOps = { + ---Memoized `token -> isSupported` probe results; a test stubbing decode_path clears it directly. + ---Requiring UnitTestSuite for hidden test exports here would cycle back into this module via Logger. + ---@private + __tokenSupport: {} + + ---Reports whether the running Aegisub resolves the given path token to a directory. + ---@param token string The token to probe, leading "?" included. + ---@return boolean isSupported False when Aegisub doesn't know the token or leaves it unset. + isTokenSupported: (token) -> + supported = PathOps.__tokenSupport[token] + unless supported == nil + return supported + -- a token Aegisub can't resolve comes back verbatim, a resolved one as its directory + supported = aegisub.decode_path(token) != token + PathOps.__tokenSupport[token] = supported + return supported + + ---Resolves every Aegisub path token in a path against the running build. + ---Token matching is prefix-based, as in Aegisub itself, so a separator after the token is optional. + ---@param path string A path that may start with an Aegisub path token. + ---@return string decodedPath The path with all tokens resolved to absolute directories. + decode: (path) -> + for token, fallback in pairs fallbacks + continue unless path\sub(1, #token) == token + continue if PathOps.isTokenSupported token + path = fallback .. path\sub #token + 1 + break + return aegisub.decode_path path +} + +return PathOps diff --git a/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon index a51fe95..1cd6f35 100644 --- a/modules/l0/DependencyControl/test.moon +++ b/modules/l0/DependencyControl/test.moon @@ -32,6 +32,7 @@ return UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> Environment: (controls\requireTest "environment")! Utils: (controls\requireTest "utils")! FileOps: (controls\requireTest "file-ops") basePath, isWindows + PathOps: (controls\requireTest "path-ops")! Logger: (controls\requireTest "Logger")! UnitTestSuite: (controls\requireTest "UnitTestSuite")! Enum: (controls\requireTest "Enum")! diff --git a/modules/l0/DependencyControl/test/config-schema.moon b/modules/l0/DependencyControl/test/config-schema.moon index 77e8818..7664f02 100644 --- a/modules/l0/DependencyControl/test/config-schema.moon +++ b/modules/l0/DependencyControl/test/config-schema.moon @@ -21,11 +21,14 @@ 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 + -- the logging policy literals (which deliberately differ from Logger's own defaults) and the XDG-aligned + -- path bases: settings under the config token, caches under the cache token, logs under the state token hasPolicyLiterals: (ut) -> ut\assertEquals schema.sections.logging.defaultLevel, 3 ut\assertTrue schema.sections.logging.toFile - ut\assertEquals schema.sections.paths.cache, "?user/cache" + ut\assertEquals schema.sections.paths.config, "?user/config" + ut\assertEquals schema.sections.paths.cache, "?local/cache" + ut\assertEquals schema.sections.paths.log, "?state/log" -- 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. diff --git a/modules/l0/DependencyControl/test/path-ops.moon b/modules/l0/DependencyControl/test/path-ops.moon new file mode 100644 index 0000000..39de6fd --- /dev/null +++ b/modules/l0/DependencyControl/test/path-ops.moon @@ -0,0 +1,77 @@ +-- path-ops tests: a path token the running Aegisub resolves is decoded as-is, one it doesn't know +-- is swapped for its fallback token first, and the support probe runs once per token. Uses a stubbed +-- decode_path standing in for Aegisub builds with and without ?state; no filesystem access. +-- Called from test.moon as: (require "…test.path-ops")! +() -> + pathOps = require "l0.DependencyControl.path-ops" + + -- The support probe is memoized for the process, so results carried over from another Aegisub — + -- the real one the suite runs under, or the previous test's — have to go before each stub. + clearProbeCache = -> pathOps.__tokenSupport[token] = nil for token in pairs pathOps.__tokenSupport + + -- Stands in for aegisub.decode_path over the given `token -> directory` map: a token in the map + -- resolves to its directory, one absent from it comes back verbatim, as Aegisub does for a token + -- it doesn't know. + stubDecodePath = (ut, resolved) -> + clearProbeCache! + (ut\stub aegisub, "decode_path")\calls (path) -> + for token, dir in pairs resolved + return dir .. path\sub(#token + 1) if path\sub(1, #token) == token + return path + + withState = {"?state": "/state", "?user": "/user"} + withoutState = {"?user": "/user"} + + { + _description: "path-ops: decoding Aegisub path tokens with a fallback for tokens older builds lack." + + -- the probe reports support from whether decode_path resolved the token or handed it back + isTokenSupported_trueWhenTokenResolves: (ut) -> + stubDecodePath ut, withState + ut\assertTrue pathOps.isTokenSupported "?state" + + isTokenSupported_falseWhenTokenComesBackVerbatim: (ut) -> + stubDecodePath ut, withoutState + ut\assertFalse pathOps.isTokenSupported "?state" + + -- the probe result is memoized, so repeated decodes don't re-query Aegisub + isTokenSupported_probesOncePerToken: (ut) -> + stub = stubDecodePath ut, withState + pathOps.isTokenSupported "?state" + pathOps.isTokenSupported "?state" + stub\assertCalledOnce! + + -- a build that knows ?state decodes it directly, fallback untouched + decode_resolvesSupportedToken: (ut) -> + stubDecodePath ut, withState + ut\assertEquals pathOps.decode("?state/log"), "/state/log" + + -- a build without ?state gets the fallback token, keeping everything after it + decode_substitutesFallbackForUnsupportedToken: (ut) -> + stubDecodePath ut, withoutState + ut\assertEquals pathOps.decode("?state/log"), "/user/log" + + decode_substitutesFallbackForBareToken: (ut) -> + stubDecodePath ut, withoutState + ut\assertEquals pathOps.decode("?state"), "/user" + + -- a token with no fallback entry is handed to Aegisub untouched, supported or not + decode_passesOtherTokensThrough: (ut) -> + stubDecodePath ut, withoutState + ut\assertEquals pathOps.decode("?user/config"), "/user/config" + + decode_passesPathWithoutTokenThrough: (ut) -> + stubDecodePath ut, withoutState + ut\assertEquals pathOps.decode("/absolute/path"), "/absolute/path" + + -- probe results from the stubbed Aegisub must not outlive this class + _teardown: -> clearProbeCache! + + _order: { + "isTokenSupported_trueWhenTokenResolves", "isTokenSupported_falseWhenTokenComesBackVerbatim" + "isTokenSupported_probesOncePerToken" + "decode_resolvesSupportedToken", "decode_substitutesFallbackForUnsupportedToken" + "decode_substitutesFallbackForBareToken", "decode_passesOtherTokensThrough" + "decode_passesPathWithoutTokenThrough" + } + } diff --git a/schemas/config/v0.7.0.json b/schemas/config/v0.7.0.json index 7009ff5..90869c2 100644 --- a/schemas/config/v0.7.0.json +++ b/schemas/config/v0.7.0.json @@ -168,7 +168,7 @@ } }, "paths": { - "description": "Base directories for DependencyControl's files. Each supports Aegisub path tokens such as `?user` and `?data`.", + "description": "Base directories for DependencyControl's files. Each supports Aegisub path tokens such as `?user`, `?local` and `?state`. An Aegisub build that doesn't know `?state` gets `?user` instead.", "type": "object", "additionalProperties": false, "properties": { @@ -180,12 +180,12 @@ "log": { "description": "Directory holding DependencyControl's log files.", "type": "string", - "default": "?user/log" + "default": "?state/log" }, "cache": { "description": "Base directory for DependencyControl's on-disk caches. Each cache lives in a `/` subdirectory beneath it (e.g. the feed cache at `/l0.DependencyControl/feeds`).", "type": "string", - "default": "?user/cache" + "default": "?local/cache" } } } From e803999b69766f3faf4bf2fda5f9b5f432c24e47 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 2 Aug 2026 12:06:58 +0200 Subject: [PATCH 2/7] refactor(paths): move pure path handling from FileOps into PathOps FileOps requires Logger, so nothing at or below Logger in the dependency graph can reach its path helpers; Logger assembles log file paths by hand for that reason. PathOps sits below Logger and now holds the handling that never touches disk: joinPath, pathSegments, validateFullPath, getTempDir, __getPathRoot, the long-path detection and the path limit constants. FileOps re-exports each under its existing name, so external callers are unaffected. getNamespacedPath stays behind, since domain.validateNamespace reaches Logger through Enum and would close the cycle. The path tests move to the PathOps suite, where the limit overrides have to target the fields the path code actually reads. Co-Authored-By: Claude Opus 5 (1M context) --- DependencyControl.json | 37 +- modules/l0/DependencyControl/domain.moon | 29 ++ modules/l0/DependencyControl/file-ops.moon | 330 ++++-------------- modules/l0/DependencyControl/path-ops.moon | 249 ++++++++++++- modules/l0/DependencyControl/test.moon | 6 +- modules/l0/DependencyControl/test/domain.moon | 36 +- .../l0/DependencyControl/test/file-ops.moon | 221 +----------- .../l0/DependencyControl/test/path-ops.moon | 198 ++++++++++- 8 files changed, 599 insertions(+), 507 deletions(-) diff --git a/DependencyControl.json b/DependencyControl.json index aeec769..d21868b 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -62,7 +62,7 @@ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "E6C82B7325F2191288BE3A25BA9EDA9BE90436E1" + "sha1": "C854C26EBE7D1BF3B59D5B5A6291C2C4E55D0338" }, { "name": ".moon", @@ -131,7 +131,7 @@ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "9AA2EBC0ADC9B263C9895962CE0E351D010C80B8" + "sha1": "C116B117A6420107E3FD3A2A6B689DEB88AC953B" }, { "name": "/Constants.moon", @@ -171,7 +171,7 @@ { "name": "/FileCache.moon", "url": "@{fileBaseUrl}", - "sha1": "600F604B26D4C74D7FDC1F5BEA37512A49AF983F" + "sha1": "4E954C863FC0A9312966797D285CA8CF22D27C16" }, { "name": "/FeedInventory.moon", @@ -196,7 +196,7 @@ { "name": "/file-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "2FB92DBDC1AA861B7D5A859B165CE71C39E8FBD0" + "sha1": "C325CC4B19EE226CAB1D7D06B8229B514DD014E6" }, { "name": "/FileOps.moon", @@ -227,7 +227,7 @@ { "name": "/Logger.moon", "url": "@{fileBaseUrl}", - "sha1": "CBFC4B9C5699D3606D7AEE92137DA91DEC1B4EBD" + "sha1": "C9C32774B31904ED7B90864B4766802C754EAB3B" }, { "name": "/ModuleLoader.moon", @@ -247,7 +247,7 @@ { "name": "/PackageRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "8BF956EEF2223D75B42FEBAE3BE21E2FC2A1CDA7" + "sha1": "79D08D8501FAB34794523593444DBAAF160743BA" }, { "name": "/ScriptTargetFilter.moon", @@ -347,7 +347,7 @@ { "name": "/config-schema.moon", "url": "@{fileBaseUrl}", - "sha1": "CC74ACBE7A7CD8761AD714564F3805AC96F68067" + "sha1": "F6FD0E470370511689CA5B01F28D8BC3BCD88F39" }, { "name": "/Finalizer.moon", @@ -362,7 +362,7 @@ { "name": "/domain.moon", "url": "@{fileBaseUrl}", - "sha1": "C5D9BC686DFA4A85A28EE6E2E392B1E012A83909" + "sha1": "12BED2BD17AA6364130D30CF0BD20630E9A44597" }, { "name": "/environment.moon", @@ -374,10 +374,15 @@ "url": "@{fileBaseUrl}", "sha1": "FA8307BC15AD2FEC1535F9B862E1D92C2A3A1AB9" }, + { + "name": "/path-ops.moon", + "url": "@{fileBaseUrl}", + "sha1": "32B0D42A6A549F4CB1B3E2775E79F2565E478A19" + }, { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "1FE9D211C256E84F4C0F95BBA10659A1F7B9A0C7", + "sha1": "71CC2ABE5FA6CC5B68233CF8CCF89B45229411D5", "type": "test" }, { @@ -455,7 +460,7 @@ { "name": "/file-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "AB2BBDDFEDBB158963CFDFAE196C584E105598CC", + "sha1": "588E50609A52399576D74B8090131B071CBA6823", "type": "test" }, { @@ -611,7 +616,7 @@ { "name": "/config-schema.moon", "url": "@{fileBaseUrl}", - "sha1": "C65BC75E4ACF11F2CA174255CB6792C3C4FAD7A7", + "sha1": "A1C2BE15B2B155BFDD4BC9BC04E14D18F7D3ADC4", "type": "test" }, { @@ -635,7 +640,7 @@ { "name": "/domain.moon", "url": "@{fileBaseUrl}", - "sha1": "2406064A5B8F234ECB69E9C60F5FC1A5E9B7657A", + "sha1": "6CEFB94846D96267E745F0F8A06D37147E234E2D", "type": "test" }, { @@ -649,6 +654,12 @@ "url": "@{fileBaseUrl}", "sha1": "1889EFB487A85EF9CD9838312B36CAB97D332119", "type": "test" + }, + { + "name": "/path-ops.moon", + "url": "@{fileBaseUrl}", + "sha1": "69E5588A097CFB08CF8FF7A63555CBD6ECFDF6E2", + "type": "test" } ], "requiredModules": [ @@ -785,7 +796,7 @@ ], "0.9.0": [ "change: Log files and on-disk caches now follow the XDG Base Directory specification, moving to `?state/log` and `?local/cache`. An Aegisub build that lacks the newer `?state` path token keeps writing logs to `?user/log`, so a path setting naming `?state` works on every version.", - "feat: Added `DependencyControl.PathOps` for resolving Aegisub path tokens with a fallback for tokens the running Aegisub build doesn't know." + "feat: Added `DependencyControl.PathOps`, a filesystem-free home for path composition, validation, and Aegisub path-token resolution. `joinPath`, `validateFullPath`, `pathSegments`, `getTempDir` and the path constants moved there from `FileOps`, which still re-exports each under its existing name." ] } }, diff --git a/modules/l0/DependencyControl/domain.moon b/modules/l0/DependencyControl/domain.moon index 50e8f67..65363cc 100644 --- a/modules/l0/DependencyControl/domain.moon +++ b/modules/l0/DependencyControl/domain.moon @@ -1,9 +1,14 @@ Enum = require "l0.DependencyControl.Enum" +pathOps = require "l0.DependencyControl.path-ops" 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-_]." } + 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." + } } ---Whether a record is managed (installed and updated) or unmanaged (tracked only). @@ -46,6 +51,9 @@ FetchUntrustedFeeds = Enum "FetchUntrustedFeeds", { Prompt: "prompt" } +-- forward-declared so the members below close over the local rather than a global of the same name +local Domain + ---Shared vocabulary of DependencyControl's problem domain: the kinds of scripts and records it ---manages, the human-readable terms for them, namespace rules, and install/test locations. ---@class Domain @@ -105,6 +113,27 @@ Domain = { 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 + + ---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 = Domain.validateNamespace namespace + return nil, msg unless res + + fullBasePath, msg = pathOps.validateFullPath basePath + return nil, msgs.getNamespacedPath.badBasePath\format basePath, msg unless fullBasePath + + namespacePath = "#{nested and namespace\gsub("%.", pathOps.pathSep) or namespace}#{ext}" + normalizedFullPath, msg = pathOps.validateFullPath namespacePath, false, fullBasePath + return nil, msgs.getNamespacedPath.badPath\format fullBasePath, namespacePath, msg unless normalizedFullPath + + return normalizedFullPath } return Domain diff --git a/modules/l0/DependencyControl/file-ops.moon b/modules/l0/DependencyControl/file-ops.moon index f91691a..ebddc5e 100644 --- a/modules/l0/DependencyControl/file-ops.moon +++ b/modules/l0/DependencyControl/file-ops.moon @@ -1,10 +1,8 @@ -ffi = require "ffi" lfs = require "lfs" constants = require "l0.DependencyControl.Constants" Logger = require "l0.DependencyControl.Logger" domain = require "l0.DependencyControl.domain" pathOps = require "l0.DependencyControl.path-ops" -utils = require "l0.DependencyControl.utils" Hash = require "l0.DependencyControl.hash" ENOENT = 2 -- POSIX error code for "No such file or directory" @@ -13,62 +11,6 @@ ERROR_PATH_NOT_FOUND = 3 -- Windows error code for "The system cannot find the p local ConfigView, FileOps --- 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 - ----Reports whether the current process can use paths beyond the legacy MAX_PATH limit. ----@return boolean enabled True when this process may use long paths. -detectProcessLongPathsEnabled = -> - -- ntdll!RtlAreLongPathsEnabled gives the effective per-process answer, folding 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. The symbol arrived in Windows 10 1607, when long paths were introduced. On older systems - -- it is absent and long paths are unsupported, so they read as disabled. - 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 the system-wide LongPathsEnabled policy from the Windows registry (HKLM\…\Control\FileSystem). ----@return boolean enabled True when the value is present and set to 1; false when it is missing, zero, or unreadable. -detectRegistryLongPathsEnabled = -> - -- This reflects the system policy only, not the per-process manifest. It exists just to tailor - -- the diagnostic when a path is rejected — telling "long paths are off system-wide" apart from - -- "they're on, but this application isn't long-path-aware". - 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 - defaultLogger = Logger! msgs = { @@ -104,9 +46,6 @@ msgs = { 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" @@ -144,36 +83,8 @@ msgs = { 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" -}} - --- 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 - ---Lazily creates and caches the FileOps deletion-tracking config on the module table. ---@param noLoad? boolean Don't read the file from disk when the handler is created. ---@param configDir? string Directory holding the config; sets or overrides the cached location. @@ -195,9 +106,9 @@ createConfig = (noLoad, configDir) -> ---@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 - accumulator, first = dir\match("^[/\\]") and FileOps.pathSep or "", true - for segment in FileOps.pathSegments dir - accumulator = first and accumulator .. segment or "#{accumulator}#{FileOps.pathSep}#{segment}" + accumulator, first = dir\match("^[/\\]") and pathOps.pathSep or "", true + for segment in pathOps.pathSegments dir + accumulator = first and accumulator .. segment or "#{accumulator}#{pathOps.pathSep}#{segment}" first = false continue if accumulator\match "^%a:$" -- skip bare drive letters like "C:" unless lfs.attributes accumulator, "mode" @@ -217,36 +128,79 @@ mkdirRecursive = (dir) -> ---Filesystem utility helpers used by DependencyControl. ---@class FileOps FileOps = { - pathSep: ffi.os == "Windows" and "\\" or "/" - pathMatch: { - sep: ffi.os == "Windows" and "\\" or "/" - sepAll: ffi.os == "Windows" and "[\\/]" or "/" - invalidChars: '[<>:"|%?%*%z%c;]' - } + ---Path separator for the host platform. Deprecated re-export. + ---@deprecated Use `PathOps.pathSep`. + ---@type string + pathSep: pathOps.pathSep + + ---Path patterns for the host platform. Deprecated re-export. + ---@deprecated Use `PathOps.pathMatch`. + ---@type PathMatchPatterns + pathMatch: pathOps.pathMatch + + ---Effective full-path length limit for this process. Deprecated re-export. + ---@deprecated Use `PathOps.pathMaxLength`. ---@type integer - pathMaxLength: pathMaxLength - 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 + pathMaxLength: pathOps.pathMaxLength + + ---Per-component path length limit. Deprecated re-export. + ---@deprecated Use `PathOps.pathMaxSegmentLength`. + ---@type integer + pathMaxSegmentLength: pathOps.pathMaxSegmentLength + + ---Whether this process is capped at the legacy Windows MAX_PATH limit. Deprecated re-export. + ---@deprecated Use `PathOps.longPathsDisabled`. + ---@type boolean + longPathsDisabled: pathOps.longPathsDisabled + + ---Whether the Windows registry policy enables long paths. Deprecated re-export. + ---@deprecated Use `PathOps.windowsRegistryLongPathsEnabled`. + ---@type boolean + windowsRegistryLongPathsEnabled: pathOps.windowsRegistryLongPathsEnabled + + ---@deprecated Use `PathOps.getTempDir`. + ---@return string tempDirPath Absolute path to a unique, not-yet-existing temporary directory. + getTempDir: pathOps.getTempDir + + ---@deprecated Use `PathOps.joinPath`. + ---@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: pathOps.joinPath + + ---@deprecated Use `PathOps.pathSegments`. + ---@param path string + ---@return fun(): string? iterator Yields the path's non-empty components in order. + pathSegments: pathOps.pathSegments + + ---@deprecated Use `PathOps.validateFullPath`. + ---@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: pathOps.validateFullPath + + ---@deprecated Use `Domain.getNamespacedPath`. + ---@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 The namespaced path, or nil when the namespace or base path is invalid. + ---@return string? err + getNamespacedPath: domain.getNamespacedPath ---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() + tempDir = pathOps.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). @@ -390,7 +344,7 @@ FileOps = { return nil, err unless entries files = {} for entry in *entries - fullPath = FileOps.joinPath dirPath, entry + fullPath = pathOps.joinPath dirPath, entry info = FileOps.getAttributes fullPath, "mode" mode = info and info.attr if mode == "directory" @@ -400,54 +354,6 @@ FileOps = { 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 = utils.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 @@ -582,7 +488,7 @@ FileOps = { if recurse -- recursively remove contained files and directories - toRemove = [FileOps.joinPath(path, file) for file in *FileOps.listDir path] + toRemove = [pathOps.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" @@ -631,7 +537,7 @@ FileOps = { ---@return FileOpsAttributesInfo? info The attributes and path components, or nil on a hard error (an invalid path or an lfs failure). A path that simply doesn't exist is not an error: `info.attr` is then false. ---@return string? err An error message, present only when info is nil. getAttributes: (path, key) -> - fullPath, dev, dir, file = FileOps.validateFullPath path, false, lfs.currentdir! + fullPath, dev, dir, file = pathOps.validateFullPath path, false, lfs.currentdir! unless fullPath return nil, msgs.attributes.badPath\format dev @@ -672,104 +578,6 @@ FileOps = { return true if not expectedMode or info.attr == expectedMode return false, msgs.exists.wrongType\format info.path, expectedMode, info.attr - ---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 = pathOps.decode 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 = domain.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 } return FileOps diff --git a/modules/l0/DependencyControl/path-ops.moon b/modules/l0/DependencyControl/path-ops.moon index 664677f..7a986fa 100644 --- a/modules/l0/DependencyControl/path-ops.moon +++ b/modules/l0/DependencyControl/path-ops.moon @@ -1,15 +1,129 @@ +ffi = require "ffi" +constants = require "l0.DependencyControl.Constants" +utils = require "l0.DependencyControl.utils" + +-- 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 + ---Stand-in token for each token only newer Aegisub builds resolve, keyed by the newer token. fallbacks = { "?state": "?user" } +msgs = { + joinPath: { + invalidSegment: "Invalid path segment type: expected a string or pure array table, got '%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" +}} + +---Reports whether the current process can use paths beyond the legacy MAX_PATH limit. +---@return boolean enabled True when this process may use long paths. +detectProcessLongPathsEnabled = -> + -- ntdll!RtlAreLongPathsEnabled gives the effective per-process answer, folding 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. The symbol arrived in Windows 10 1607, when long paths were introduced. On older systems + -- it is absent and long paths are unsupported, so they read as disabled. + 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 the system-wide LongPathsEnabled policy from the Windows registry (HKLM\…\Control\FileSystem). +---@return boolean enabled True when the value is present and set to 1; false when it is missing, zero, or unreadable. +detectRegistryLongPathsEnabled = -> + -- This reflects the system policy only, not the per-process manifest. It exists just to tailor + -- the diagnostic when a path is rejected — telling "long paths are off system-wide" apart from + -- "they're on, but this application isn't long-path-aware". + 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 + +-- 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 + +---Lua patterns for matching path structure on the host platform. +---@class PathMatchPatterns +---@field sep string The native separator, escaped for use in a pattern. +---@field sepAll string Character class matching either separator. +---@field invalidChars string Character class matching characters no path component may contain. + -- forward-declared so the members below close over the local rather than a global of the same name local PathOps ----Decodes Aegisub path tokens, substituting a fallback for a token the running build can't resolve. ----`?state` falls back to `?user`, so a path naming it works on an Aegisub that predates the token. +---Path composition, validation and normalization, plus Aegisub path-token resolution; nothing here +---touches the filesystem. `?state` falls back to `?user` on a build that predates the token. ---@class PathOps PathOps = { + ---@type string + pathSep: ffi.os == "Windows" and "\\" or "/" + ---@type PathMatchPatterns + pathMatch: { + sep: ffi.os == "Windows" and "\\" or "/" + sepAll: ffi.os == "Windows" and "[\\/]" or "/" + invalidChars: '[<>:"|%?%*%z%c;]' + } + ---@type integer + pathMaxLength: pathMaxLength + 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 + ---Memoized `token -> isSupported` probe results; a test stubbing decode_path clears it directly. ---Requiring UnitTestSuite for hidden test exports here would cycle back into this module via Logger. ---@private @@ -38,6 +152,137 @@ PathOps = { path = fallback .. path\sub #token + 1 break return aegisub.decode_path path + + ---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 PathOps.decode "?temp/#{constants.DEPCTRL_NAMESPACE}_#{'%04X'\format math.random 0, 16^4-1}" + + ---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 PathOps.__getPathRoot firstStr + + invalidPathSegmentType = nil + flatPathSegments = utils.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 PathOps.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 PathOps.pathSep or ""}#{table.concat segments, PathOps.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 "[^/\\]+" + + ---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 = PathOps.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 = PathOps.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 = PathOps.decode path + -- expand home directory on linux + homeDir = os.getenv "HOME" + path = path\gsub "^~", "#{homeDir}/" if homeDir + -- use single native path separators + path = path\gsub "[\\/]+", PathOps.pathSep + -- check length + if #path > PathOps.pathMaxLength + if PathOps.longPathsDisabled + -- distinguish a system-wide opt-out from an app that isn't long-path-aware + if PathOps.windowsRegistryLongPathsEnabled + return nil, msgs.validateFullPath.tooLongProcessUnaware\format #path, PathOps.pathMaxLength, PathOps.pathMaxLength + return nil, msgs.validateFullPath.tooLongRegistryDisabled\format #path, PathOps.pathMaxLength + return nil, msgs.validateFullPath.tooLong\format #path, PathOps.pathMaxLength + -- check for invalid characters + invChar = path\match PathOps.pathMatch.invalidChars, ffi.os == "Windows" and 3 or nil + if invChar + return nil, msgs.validateFullPath.invalidChars\format invChar + -- check if path is absolute + dev = PathOps.__getPathRoot path + unless dev + -- make relative paths absolute if base path is provided + if basePath + path, errMsg = PathOps.joinPath basePath, path + return nil, errMsg if not path + dev = PathOps.__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 PathOps.pathSegments rest + if #segment > PathOps.pathMaxSegmentLength + return nil, msgs.validateFullPath.segmentTooLong\format #segment, PathOps.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 PathOps.pathSep, file} + return path, dev, dir, file } return PathOps diff --git a/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon index 1cd6f35..d7861fd 100644 --- a/modules/l0/DependencyControl/test.moon +++ b/modules/l0/DependencyControl/test.moon @@ -28,11 +28,11 @@ return UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> Hash: (controls\requireTest "hash")! ModuleProvider: (controls\requireTest "ModuleProvider") basePath, DepCtrl Downloader: (controls\requireTest "Downloader") basePath - Domain: (controls\requireTest "domain")! + Domain: (controls\requireTest "domain") basePath Environment: (controls\requireTest "environment")! Utils: (controls\requireTest "utils")! - FileOps: (controls\requireTest "file-ops") basePath, isWindows - PathOps: (controls\requireTest "path-ops")! + FileOps: (controls\requireTest "file-ops") basePath + PathOps: (controls\requireTest "path-ops") basePath, isWindows Logger: (controls\requireTest "Logger")! UnitTestSuite: (controls\requireTest "UnitTestSuite")! Enum: (controls\requireTest "Enum")! diff --git a/modules/l0/DependencyControl/test/domain.moon b/modules/l0/DependencyControl/test/domain.moon index 6a69010..4531ad7 100644 --- a/modules/l0/DependencyControl/test/domain.moon +++ b/modules/l0/DependencyControl/test/domain.moon @@ -1,7 +1,9 @@ --- Domain tests: namespace validation, shared terms, and install/test directory resolution. --- Called from test.moon as: (controls\requireTest "domain")! --> +-- Domain tests: namespace validation, shared terms, install/test directory resolution, and the +-- namespace-to-path mapping. +-- Called from test.moon as: (controls\requireTest "domain") basePath +(basePath) -> domain = require "l0.DependencyControl.domain" + pathOps = require "l0.DependencyControl.path-ops" { _description: "Tests for the DependencyControl domain vocabulary: namespace validation, terms, and install locations." @@ -88,6 +90,30 @@ ut\assertContains result, "myRoot" ut\assertContains result, "DepUnit" + -- getNamespacedPath: pure computation, no stubs needed + + getNamespacedPath_nested: (ut) -> + path, err = domain.getNamespacedPath basePath, "l0.DependencyControl.Test", ".lua" + ut\assertNil err + ut\assertString path + ut\assertContains path, pathOps.joinPath "l0", "DependencyControl", "Test.lua" + + getNamespacedPath_flat: (ut) -> + path, err = domain.getNamespacedPath basePath, "l0.DependencyControl", ".lua", false + ut\assertNil err + ut\assertString path + ut\assertContains path, "l0.DependencyControl.lua" + + getNamespacedPath_badNamespace: (ut) -> + path, err = domain.getNamespacedPath basePath, "not-a-namespace", ".lua" + ut\assertNil path + ut\assertString err + + getNamespacedPath_badBasePath: (ut) -> + path, err = domain.getNamespacedPath {"relative", "path"}, "l0.DependencyControl", ".lua" + ut\assertNil path + ut\assertString err + _order: { "capitalizeTerms", "validateNamespace_valid", "validateNamespace_multiPart", @@ -96,6 +122,8 @@ "validateNamespace_consecutiveDots", "getAutomationDir_automation", "getAutomationDir_module", "getAutomationDir_customRoot", "getAutomationDir_unknown", - "getTestDir_automation", "getTestDir_module", "getTestDir_customRoot" + "getTestDir_automation", "getTestDir_module", "getTestDir_customRoot", + "getNamespacedPath_nested", "getNamespacedPath_flat", + "getNamespacedPath_badNamespace", "getNamespacedPath_badBasePath" } } diff --git a/modules/l0/DependencyControl/test/file-ops.moon b/modules/l0/DependencyControl/test/file-ops.moon index e615fb6..e71c6bd 100644 --- a/modules/l0/DependencyControl/test/file-ops.moon +++ b/modules/l0/DependencyControl/test/file-ops.moon @@ -1,158 +1,13 @@ --- FileOps tests: path validation and filesystem utilities. --- Called from Tests.moon as: (require "...test.FileOps") basePath, isWindows -(basePath, isWindows) -> - ffi = require "ffi" +-- FileOps tests: filesystem utilities. Path composition and validation live in the PathOps tests. +-- Called from test.moon as: (require "...test.file-ops") basePath +(basePath) -> lfs = require "lfs" fileOps = require "l0.DependencyControl.file-ops" - pathSep = isWindows and "\\" or "/" FILEOPS_MODULE_NAME = "l0.DependencyControl.file-ops" - -- 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.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 + _description: "Tests for fileOps filesystem utilities." -- getAttributes: stubs lfs.attributes -- lfs.attributes(path, key) returns (value) on success, (nil) when not found, or @@ -190,26 +45,6 @@ ut\assertEquals mode, "file" ut\assertString fullPath - -- 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) -> @@ -390,40 +225,6 @@ 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) -> @@ -494,16 +295,6 @@ 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", "getAttributes_file", "getAttributes_notFound", "getAttributes_error", "attributes_deprecatedShim", "mkdir_new", "mkdir_exists", "mkdir_acceptsSilentLfsSuccess", "mkdir_silentLfsFailure", "rmdir_acceptsSilentLfsSuccess", @@ -513,10 +304,6 @@ "copy_success", "copy_targetExists", "move_overwrite", "remove_success", "remove_notFound", "remove_hardFailureReported", "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/path-ops.moon b/modules/l0/DependencyControl/test/path-ops.moon index 39de6fd..a6ac639 100644 --- a/modules/l0/DependencyControl/test/path-ops.moon +++ b/modules/l0/DependencyControl/test/path-ops.moon @@ -1,11 +1,26 @@ --- path-ops tests: a path token the running Aegisub resolves is decoded as-is, one it doesn't know --- is swapped for its fallback token first, and the support probe runs once per token. Uses a stubbed --- decode_path standing in for Aegisub builds with and without ?state; no filesystem access. --- Called from test.moon as: (require "…test.path-ops")! -() -> +-- path-ops tests: joining and resolving path segments, validating and normalizing full paths against +-- the platform's length/character/reserved-name rules, and decoding Aegisub path tokens with a fallback +-- for a token the running build lacks. Pure computation over a temp base path and a stubbed +-- decode_path; nothing here touches the filesystem. +-- Called from test.moon as: (require "…test.path-ops") basePath, isWindows +(basePath, isWindows) -> pathOps = require "l0.DependencyControl.path-ops" + pathSep = isWindows and "\\" or "/" - -- The support probe is memoized for the process, so results carried over from another Aegisub — + -- Runs fn with the 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 = {pathOps.pathMaxLength, pathOps.longPathsDisabled, pathOps.windowsRegistryLongPathsEnabled} + pathOps.pathMaxLength = maxLength + pathOps.longPathsDisabled = longPathsDisabled + pathOps.windowsRegistryLongPathsEnabled = registryEnabled + results = table.pack pcall fn + pathOps.pathMaxLength, pathOps.longPathsDisabled, pathOps.windowsRegistryLongPathsEnabled = saved[1], saved[2], saved[3] + error results[2] unless results[1] + return unpack results, 2, results.n + + -- The token-support probe is memoized for the process, so results carried over from another Aegisub — -- the real one the suite runs under, or the previous test's — have to go before each stub. clearProbeCache = -> pathOps.__tokenSupport[token] = nil for token in pairs pathOps.__tokenSupport @@ -23,9 +38,166 @@ withoutState = {"?user": "/user"} { - _description: "path-ops: decoding Aegisub path tokens with a fallback for tokens older builds lack." + _description: "path-ops: path composition and validation, plus Aegisub path-token decoding." + + -- joinPath: pure computation, no stubs needed + + joinPath_segmentsArray: (ut) -> + result = pathOps.joinPath {"path", "to", "file.txt"} + ut\assertEquals result, "path#{pathSep}to#{pathSep}file.txt" + + joinPath_segmentsVarargs: (ut) -> + result = pathOps.joinPath "path", "to", "file.txt" + ut\assertEquals result, "path#{pathSep}to#{pathSep}file.txt" + + joinPath_segmentsMixed: (ut) -> + result = pathOps.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 pathOps.joinPath("path", "", "file.txt"), "path#{pathSep}file.txt" + ut\assertEquals pathOps.joinPath("path", {}, "file.txt"), "path#{pathSep}file.txt" + ut\assertEquals pathOps.joinPath("a", "b/c", "d"), "a#{pathSep}b#{pathSep}c#{pathSep}d" + + joinPath_resolvesDotDot: (ut) -> + result = pathOps.joinPath "a", "b", "..", "c" + ut\assertEquals result, "a#{pathSep}c" + + joinPath_invalidSegment: (ut) -> + result, err = pathOps.joinPath 42 + ut\assertNil result + ut\assertString err + + -- __getPathRoot + + getPathRoot_windowsPath: (ut) -> + return unless isWindows + result = pathOps.__getPathRoot "C:\\Users\\foo" + ut\assertEquals result, "C:\\" + + getPathRoot_posixPath: (ut) -> + return if isWindows + result = pathOps.__getPathRoot "/usr/local" + ut\assertEquals result, "/usr" + + getPathRoot_relative: (ut) -> + result = pathOps.__getPathRoot "relative/path" + ut\assertNil result + + -- validateFullPath: pure computation, no stubs needed + + validateFullPath_nonString: (ut) -> + result, err = pathOps.validateFullPath 42 + ut\assertNil result + ut\assertString err + + validateFullPath_parentDir: (ut) -> + -- ".." is resolved rather than rejected + result = pathOps.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 = pathOps.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, -> + pathOps.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 pathOps.pathMaxSegmentLength, 255 + if isWindows + -- 260 (capped) or 32767 (long paths available to this process) + ut\assertTrue pathOps.pathMaxLength == 260 or pathOps.pathMaxLength == 32767 + ut\assertBoolean pathOps.longPathsDisabled + else + ut\assertEquals pathOps.pathMaxLength, 4096 + ut\assertFalse pathOps.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, -> + pathOps.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, -> + pathOps.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, -> + pathOps.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 = pathOps.validateFullPath {basePath, "with.txt"} + ut\assertNil result + + validateFullPath_reservedNames: (ut) -> + return unless isWindows + result = pathOps.validateFullPath {basePath, "CON", "file.txt"} + ut\assertNil result + + validateFullPath_reservedNameWithExt: (ut) -> + return unless isWindows + result = pathOps.validateFullPath {basePath, "NUL.txt"} + ut\assertNil result + + validateFullPath_trailingDotSegment: (ut) -> + result = pathOps.validateFullPath {basePath, "trailingDot.", "file.txt"} + ut\assertNil result + + validateFullPath_valid: (ut) -> + path, dev, dir, file = pathOps.validateFullPath {basePath, "file.txt"} + ut\assertString path + ut\assertString dev + ut\assertEquals file, "file.txt" + + validateFullPath_noExt_rejected: (ut) -> + result = pathOps.validateFullPath {basePath, "no-ext"}, true + ut\assertFalse result + + validateFullPath_withExt_accepted: (ut) -> + result = pathOps.validateFullPath {basePath, "file.txt"}, true + ut\assertString result + + validateFullPath_homeDirExpansion: (ut) -> + return if isWindows + home = os.getenv "HOME" + return unless home + result = pathOps.validateFullPath {"~", "subdir", "file.txt"} + ut\assertString result + ut\assertContains result, home + + validateFullPath_reservedNameNonWindows: (ut) -> + return if isWindows + result = pathOps.validateFullPath {basePath, "NUL", "file.txt"} + ut\assertString result + + validateFullPath_withBasePath: (ut) -> + result = pathOps.validateFullPath "file.txt", false, basePath + ut\assertString result + ut\assertContains result, "file.txt" -- the probe reports support from whether decode_path resolved the token or handed it back + isTokenSupported_trueWhenTokenResolves: (ut) -> stubDecodePath ut, withState ut\assertTrue pathOps.isTokenSupported "?state" @@ -68,6 +240,18 @@ _teardown: -> clearProbeCache! _order: { + "joinPath_segmentsArray", "joinPath_segmentsVarargs", "joinPath_segmentsMixed" + "joinPath_skipsEmptySegments", "joinPath_resolvesDotDot", "joinPath_invalidSegment" + "getPathRoot_windowsPath", "getPathRoot_posixPath", "getPathRoot_relative" + "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" + "validateFullPath_withBasePath" "isTokenSupported_trueWhenTokenResolves", "isTokenSupported_falseWhenTokenComesBackVerbatim" "isTokenSupported_probesOncePerToken" "decode_resolvesSupportedToken", "decode_substitutesFallbackForUnsupportedToken" From 2413f0b4388538f22a0e6f8ab03ba2f8dee24c6a Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 2 Aug 2026 12:58:19 +0200 Subject: [PATCH 3/7] feat(paths): clean up the files left in the pre-0.9.0 log and cache locations Identification, not location, decides what goes: a log file matching the name Logger gives DepCtrl's own, and a cache index whose contents decode as one plus the snapshot it names. Aegisub's logs shared the old log directory before it gained ?state, and a cache directory holding no decodable index was never ours to begin with, so both survive untouched. A directory is removed only when the sweep emptied it. Both locations are skipped when they resolve to the configured one, which is every platform where Aegisub maps ?state onto ?user, and covers a user who set the old path deliberately. No migration flag is needed: the sweep removes its own trigger, so it costs one directory probe once the location is gone, and a file it cannot delete is retried on the next run. Co-Authored-By: Claude Opus 5 (1M context) --- DependencyControl.json | 27 +++-- modules/l0/DependencyControl/FileCache.moon | 79 +++++++++--- .../l0/DependencyControl/PackageRecord.moon | 2 + .../l0/DependencyControl/legacy-cleanup.moon | 79 ++++++++++++ modules/l0/DependencyControl/path-ops.moon | 5 +- modules/l0/DependencyControl/test.moon | 1 + .../l0/DependencyControl/test/FileCache.moon | 67 +++++++++- .../test/legacy-cleanup.moon | 114 ++++++++++++++++++ 8 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 modules/l0/DependencyControl/legacy-cleanup.moon create mode 100644 modules/l0/DependencyControl/test/legacy-cleanup.moon diff --git a/DependencyControl.json b/DependencyControl.json index d21868b..a32ec9d 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -62,7 +62,7 @@ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "C854C26EBE7D1BF3B59D5B5A6291C2C4E55D0338" + "sha1": "E6C82B7325F2191288BE3A25BA9EDA9BE90436E1" }, { "name": ".moon", @@ -131,7 +131,7 @@ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "C116B117A6420107E3FD3A2A6B689DEB88AC953B" + "sha1": "9AA2EBC0ADC9B263C9895962CE0E351D010C80B8" }, { "name": "/Constants.moon", @@ -171,7 +171,7 @@ { "name": "/FileCache.moon", "url": "@{fileBaseUrl}", - "sha1": "4E954C863FC0A9312966797D285CA8CF22D27C16" + "sha1": "E9078D98329F984499A6D68AE79359F962AC9A03" }, { "name": "/FeedInventory.moon", @@ -247,7 +247,7 @@ { "name": "/PackageRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "79D08D8501FAB34794523593444DBAAF160743BA" + "sha1": "C77D649499C6E764C6AF07DE661EDA6E8B4C5729" }, { "name": "/ScriptTargetFilter.moon", @@ -377,12 +377,17 @@ { "name": "/path-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "32B0D42A6A549F4CB1B3E2775E79F2565E478A19" + "sha1": "26C8E3A430C7F6D5710FE775AA583C1BE855431E" + }, + { + "name": "/legacy-cleanup.moon", + "url": "@{fileBaseUrl}", + "sha1": "2838C5668DD417F9581B51DA9426857DD89B5F4F" }, { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "71CC2ABE5FA6CC5B68233CF8CCF89B45229411D5", + "sha1": "B351208EA529FD6A6D58D6A8DE1780B872D3FDE0", "type": "test" }, { @@ -424,7 +429,7 @@ { "name": "/FileCache.moon", "url": "@{fileBaseUrl}", - "sha1": "633106FDCDD0AB81CFC2BB85725B9A7B0077AF83", + "sha1": "90DF6DEEEFA062535E0F080681E2B31C9A34B60E", "type": "test" }, { @@ -660,6 +665,12 @@ "url": "@{fileBaseUrl}", "sha1": "69E5588A097CFB08CF8FF7A63555CBD6ECFDF6E2", "type": "test" + }, + { + "name": "/legacy-cleanup.moon", + "url": "@{fileBaseUrl}", + "sha1": "DAF3711D24799E032C11D0BF2B7926D518C540C8", + "type": "test" } ], "requiredModules": [ @@ -795,7 +806,7 @@ "fix: Reading the channel list of a package that declares no channels returns an empty list instead of throwing an error." ], "0.9.0": [ - "change: Log files and on-disk caches now follow the XDG Base Directory specification, moving to `?state/log` and `?local/cache`. An Aegisub build that lacks the newer `?state` path token keeps writing logs to `?user/log`, so a path setting naming `?state` works on every version.", + "change: Log files and on-disk caches now follow the XDG Base Directory specification, moving to `?state/log` and `?local/cache`; an Aegisub build that lacks the newer `?state` path token keeps writing logs to `?user/log`, so a path setting naming `?state` works on every version. Whatever an earlier release left behind in the old locations is cleaned up on startup, and only files DependencyControl can identify as its own are removed, so Aegisub's own logs stay untouched.", "feat: Added `DependencyControl.PathOps`, a filesystem-free home for path composition, validation, and Aegisub path-token resolution. `joinPath`, `validateFullPath`, `pathSegments`, `getTempDir` and the path constants moved there from `FileOps`, which still re-exports each under its existing name." ] } diff --git a/modules/l0/DependencyControl/FileCache.moon b/modules/l0/DependencyControl/FileCache.moon index 6e2c9dc..404227a 100644 --- a/modules/l0/DependencyControl/FileCache.moon +++ b/modules/l0/DependencyControl/FileCache.moon @@ -17,13 +17,39 @@ 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) -> Hash.getDigest(Hash.HashType.Sha1, key)\sub 1, 7 +SLUG_LENGTH = 7 -- hex digits of a key's digest kept as the slug every one of its files is named with +SLUG_PATTERN = "%x"\rep SLUG_LENGTH + +-- The slug of a cache key. Deterministic per key (sensitive to its exact bytes), matching the DepCtrl +-- Browser's feed-URL slug convention. +keySlug = (key) -> Hash.getDigest(Hash.HashType.Sha1, key)\sub 1, SLUG_LENGTH -- The embedded UTC timestamp of a snapshot file name, for chronological ordering ("" when absent). snapshotStamp = (fileName) -> fileName\match "(%d+T%d+Z)" or "" +-- The file names this cache writes, as the patterns that recognize them again. __metaPath and __write +-- compose those names, so these have to stay in step with them. +META_FILE_NAME_PATTERN = "^#{SLUG_PATTERN}%.meta%.json$" +SNAPSHOT_FILE_NAME_PATTERN = "^#{SLUG_PATTERN}%-.*%-%d+T%d+Z%-%x%x%x%x%.json$" +SLUG_CAPTURE_PATTERN = "^(#{SLUG_PATTERN})" + +---Reads and decodes a cache index (meta) JSON file. +---@param path string +---@return FileCacheMeta? meta Nil when the file is absent or doesn't decode to a table. +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 + +---Reports whether a decoded value carries every field this cache writes into an index. +---@param meta any A decoded index file's contents. +---@return boolean isIndex False for any other value, a table among them. +isIndex = (meta) -> + return false unless type(meta) == "table" + meta.key != nil and meta.latestFile != nil and meta.cachedAt != nil and meta.expiresAt != nil + -- 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) -> "#{pathOps.decode basePath}/#{namespace}/#{name}" @@ -75,6 +101,36 @@ class FileCache FileCache.__instances[dir] = cache return cache + ---Deletes the files this cache wrote below a directory, dropping subdirectories it empties and + ---leaving the directory it was given. + ---A directory qualifies only while it holds an index that decodes, and only its indexes and + ---snapshots are taken; every other file stays, along with the directory holding it. + ---@param dir string Absolute path of a cache root, or of a single cache's own directory. + ---@return integer removed Files deleted; 0 when the directory is absent or holds nothing of ours. + @_removeArtifactsIn = (dir) -> + entries = fileOps.listDir dir + return 0 unless entries + + removed, indexes, snapshots = 0, {}, {} + for entry in *entries + path = fileOps.joinPath dir, entry + info = fileOps.getAttributes path, "mode" + continue unless info + if info.attr == "directory" + removed += FileCache._removeArtifactsIn path + fileOps.rmdir path, false + elseif entry\match(META_FILE_NAME_PATTERN) and isIndex readMeta path + indexes[#indexes + 1] = path + elseif entry\match SNAPSHOT_FILE_NAME_PATTERN + snapshots[#snapshots + 1] = path + + -- no decodable index, so nothing here is provably ours however much it looks the part + return removed if #indexes == 0 + + removed += 1 for path in *indexes when fileOps.remove path + removed += 1 for path in *snapshots when fileOps.remove path + return removed + ---@param basePath string The cache root (the `paths.cache` setting, e.g. "?local/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"). @@ -98,12 +154,7 @@ class FileCache ---@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 + __readMeta: (path) => readMeta path ---Reads the cache index entry for a key. ---@param key string @@ -174,15 +225,15 @@ class FileCache -- 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$" + continue unless file\match META_FILE_NAME_PATTERN 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 + -- every file (snapshot or index) is named with its key's slug prefix, so one pass removes both for file in *files - slug = file\match "^(%x%x%x%x%x%x%x)" + slug = file\match SLUG_CAPTURE_PATTERN 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 @@ -247,10 +298,10 @@ class FileCache protectedFiles, snapshots = {}, {} for file in *files - if file\match "%.meta%.json$" + if file\match META_FILE_NAME_PATTERN meta = @__readMeta fileOps.joinPath @cacheDir, file protectedFiles[meta.latestFile] = true if meta and meta.latestFile - elseif file\match "%.json$" + elseif file\match SNAPSHOT_FILE_NAME_PATTERN snapshots[#snapshots + 1] = file -- newest first, so everything past the cap is the oldest diff --git a/modules/l0/DependencyControl/PackageRecord.moon b/modules/l0/DependencyControl/PackageRecord.moon index 9a88729..fd5bc6c 100644 --- a/modules/l0/DependencyControl/PackageRecord.moon +++ b/modules/l0/DependencyControl/PackageRecord.moon @@ -7,6 +7,7 @@ utils = require "l0.DependencyControl.utils" Logger = require "l0.DependencyControl.Logger" ConfigView = require "l0.DependencyControl.ConfigView" fileOps = require "l0.DependencyControl.file-ops" +legacyCleanup = require "l0.DependencyControl.legacy-cleanup" Updater = require "l0.DependencyControl.Updater" ModuleLoader = require "l0.DependencyControl.ModuleLoader" ModuleProvider = require "l0.DependencyControl.ModuleProvider" @@ -145,6 +146,7 @@ class PackageRecord fileOps.mkdir pathOps.decode @configDir @logger\trimFiles! + legacyCleanup.run paths, @logger fileOps.runScheduledRemoval @configDir diff --git a/modules/l0/DependencyControl/legacy-cleanup.moon b/modules/l0/DependencyControl/legacy-cleanup.moon new file mode 100644 index 0000000..ddf6602 --- /dev/null +++ b/modules/l0/DependencyControl/legacy-cleanup.moon @@ -0,0 +1,79 @@ +lfs = require "lfs" +constants = require "l0.DependencyControl.Constants" +FileCache = require "l0.DependencyControl.FileCache" +fileOps = require "l0.DependencyControl.file-ops" +Logger = require "l0.DependencyControl.Logger" +pathOps = require "l0.DependencyControl.path-ops" + +msgs = { + cleanLogs: { + removed: "Removed %d log file(s) left in '%s' by a pre-0.9.0 release." + } + cleanCaches: { + removed: "Removed %d cache file(s) left in '%s' by a pre-0.9.0 release." + } +} + +-- forward-declared so the members below close over the local rather than a global of the same name +local LegacyCleanup + +---Removes what DependencyControl left behind in the locations it used before 0.9.0. +---Only files it can identify as its own go, and only a directory it empties is dropped, so anything +---else sharing those locations stays. +---@class LegacyCleanup +LegacyCleanup = { + ---Path setting name mapped to the location that setting defaulted to before 0.9.0. + ---@private + __legacyPaths: { + log: "?user/log" + cache: "?user/cache" + } + + ---Removes DependencyControl's own log files from the pre-0.9.0 log directory. + ---A no-op once that directory resolves to the configured one, as it does wherever Aegisub maps + ---`?state` onto `?user`. + ---@param currentLogDir string The configured log directory, as a path or Aegisub path token. + ---@param logger Logger Receives a trace line reporting what was removed. + ---@return integer removed Log files deleted. + cleanLogs: (currentLogDir, logger) -> + dir = pathOps.decode LegacyCleanup.__legacyPaths.log + return 0 if dir == pathOps.decode currentLogDir + return 0 unless lfs.attributes dir, "mode" + + -- the trimmer matches only DepCtrl's own log file names, so Aegisub's own logs in this directory + -- survive, as does any other script's + removed = Logger(fileBaseName: constants.DEPCTRL_SHORT_NAME, logDir: dir)\trimFiles true + if removed > 0 + fileOps.rmdir dir, false + logger\trace msgs.cleanLogs.removed, removed, dir + return removed + + ---Removes DependencyControl's cached files from the pre-0.9.0 cache directory. + ---A no-op once that directory resolves to the configured one. + ---@param currentCacheDir string The configured cache root, as a path or Aegisub path token. + ---@param logger Logger Receives a trace line reporting what was removed. + ---@return integer removed Cache files deleted. + cleanCaches: (currentCacheDir, logger) -> + dir = pathOps.decode LegacyCleanup.__legacyPaths.cache + return 0 if dir == pathOps.decode currentCacheDir + return 0 unless lfs.attributes dir, "mode" + + -- FileCache names these files, so it is what recognizes them again. Its sweep is the subsystem's + -- internal contract (PV3), shared with this module rather than offered to library users. + removed = FileCache._removeArtifactsIn dir + if removed > 0 + fileOps.rmdir dir, false + logger\trace msgs.cleanCaches.removed, removed, dir + return removed + + ---Sweeps both pre-0.9.0 locations. + ---A location that is already gone costs one directory probe, and a file that cannot be deleted is + ---retried on the next call. + ---@param paths table The `paths` config section, read for the configured `log` and `cache` locations. + ---@param logger Logger Receives a trace line per location cleaned. + ---@return integer removed Files deleted across both locations. + run: (paths, logger) -> + LegacyCleanup.cleanLogs(paths.log, logger) + LegacyCleanup.cleanCaches(paths.cache, logger) +} + +return LegacyCleanup diff --git a/modules/l0/DependencyControl/path-ops.moon b/modules/l0/DependencyControl/path-ops.moon index 7a986fa..c4c2178 100644 --- a/modules/l0/DependencyControl/path-ops.moon +++ b/modules/l0/DependencyControl/path-ops.moon @@ -125,7 +125,7 @@ PathOps = { windowsRegistryLongPathsEnabled: windowsRegistryLongPathsEnabled ---Memoized `token -> isSupported` probe results; a test stubbing decode_path clears it directly. - ---Requiring UnitTestSuite for hidden test exports here would cycle back into this module via Logger. + ---UnitTestSuite loads through Logger, which loads this module, so its test exports are out of reach here. ---@private __tokenSupport: {} @@ -134,8 +134,7 @@ PathOps = { ---@return boolean isSupported False when Aegisub doesn't know the token or leaves it unset. isTokenSupported: (token) -> supported = PathOps.__tokenSupport[token] - unless supported == nil - return supported + return supported unless supported == nil -- a token Aegisub can't resolve comes back verbatim, a resolved one as its directory supported = aegisub.decode_path(token) != token PathOps.__tokenSupport[token] = supported diff --git a/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon index d7861fd..f313b03 100644 --- a/modules/l0/DependencyControl/test.moon +++ b/modules/l0/DependencyControl/test.moon @@ -33,6 +33,7 @@ return UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> Utils: (controls\requireTest "utils")! FileOps: (controls\requireTest "file-ops") basePath PathOps: (controls\requireTest "path-ops") basePath, isWindows + LegacyCleanup: (controls\requireTest "legacy-cleanup") basePath, stubHelpers Logger: (controls\requireTest "Logger")! UnitTestSuite: (controls\requireTest "UnitTestSuite")! Enum: (controls\requireTest "Enum")! diff --git a/modules/l0/DependencyControl/test/FileCache.moon b/modules/l0/DependencyControl/test/FileCache.moon index abedd99..f1f1af0 100644 --- a/modules/l0/DependencyControl/test/FileCache.moon +++ b/modules/l0/DependencyControl/test/FileCache.moon @@ -119,6 +119,18 @@ path = cache\getFile "u://f" ut\assertEquals readFile(path), '{"v":3}' + -- trimming counts only files this cache named, so a stray .json in the directory is neither + -- counted against the cap nor deleted once the cap is passed + trim_ignoresForeignFiles: (ut) -> + cache, clock = makeCache "trim-foreign", {maxFiles: 1, t: 1000} + foreign = fileOps.joinPath cache.cacheDir, "notes.json" + cache\put "u://f", '{"v":1}', "f" + fileOps.writeFile foreign, "keep me", true + clock.t = 1001 + cache\put "u://f", '{"v":2}', "f" + + ut\assertEquals readFile(foreign), "keep me" + -- 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}} @@ -177,12 +189,65 @@ ut\assertNil (cache\get "u://f") -- memo dropped and L2 gone → full miss ut\assertFalsy fileOps.getAttributes(fileOps.joinPath(cache.cacheDir, meta.latestFile), "mode").attr + -- removeArtifactsIn: recognizes what put wrote, recursing through the / layout + + removeArtifactsIn_removesWhatPutWrote: (ut) -> + root = fileOps.joinPath basePath, "filecache", "artifactsOurs" + cache = FileCache root, "testNamespace", "test" + meta = cache\put "u://f", '{"v":1}', "someFeed" + + ut\assertEquals FileCache._removeArtifactsIn(root), 2 -- the snapshot and its index + ut\assertFalsy fileOps.getAttributes(fileOps.joinPath(cache.cacheDir, meta.latestFile), "mode").attr + -- the emptied / directories go with them, the root it was given stays + ut\assertFalsy fileOps.getAttributes(cache.cacheDir, "mode").attr + ut\assertTruthy fileOps.getAttributes(root, "mode").attr + + -- a directory with no index isn't one this cache wrote, whatever its file names look like + removeArtifactsIn_keepsDirWithoutIndex: (ut) -> + dir = fileOps.joinPath basePath, "filecache", "artifactsNoIndex", "someone.else", "data" + fileOps.mkdir dir, false, true + lookalike = fileOps.joinPath dir, "0a1b2c3-report-20260801T101010Z-ABCD.json" + fileOps.writeFile lookalike, "{}", true + + ut\assertEquals FileCache._removeArtifactsIn(fileOps.joinPath basePath, "filecache", "artifactsNoIndex"), 0 + ut\assertTruthy fileOps.getAttributes(lookalike, "mode").attr + + -- an index-named file that doesn't decode as one proves nothing, and its directory stays intact + removeArtifactsIn_keepsDirWithUndecodableIndex: (ut) -> + root = fileOps.joinPath basePath, "filecache", "artifactsBadIndex" + dir = fileOps.joinPath root, "someone.else", "data" + fileOps.mkdir dir, false, true + index = fileOps.joinPath dir, "0a1b2c3.meta.json" + fileOps.writeFile index, "not json at all", true + + ut\assertEquals FileCache._removeArtifactsIn(root), 0 + ut\assertTruthy fileOps.getAttributes(index, "mode").attr + + -- files this cache didn't name survive alongside the ones it did, and keep their directory alive + removeArtifactsIn_keepsForeignFiles: (ut) -> + root = fileOps.joinPath basePath, "filecache", "artifactsForeign" + cache = FileCache root, "testNamespace", "test" + cache\put "u://f", '{"v":1}', "someFeed" + foreign = fileOps.joinPath cache.cacheDir, "notes.txt" + fileOps.writeFile foreign, "keep me", true + + ut\assertEquals FileCache._removeArtifactsIn(root), 2 + ut\assertTruthy fileOps.getAttributes(foreign, "mode").attr + ut\assertTruthy fileOps.getAttributes(cache.cacheDir, "mode").attr + + removeArtifactsIn_missingDirectory: (ut) -> + ut\assertEquals FileCache._removeArtifactsIn(fileOps.joinPath basePath, "filecache", "neverWritten"), 0 + _order: { "put_roundTrip", "put_worksWithDefaultClock", "getFile_uncached", "isFresh_window", "put_updatesLatest" "getFile_staleStillResolves", "put_sanitizesLabel" - "get_sharesInstancePerDir", "put_expiryFixedAtWriteTime", "put_perResourceExpiry", "trim_keepsLatestOverCap" + "get_sharesInstancePerDir", "put_expiryFixedAtWriteTime", "put_perResourceExpiry" + "trim_keepsLatestOverCap", "trim_ignoresForeignFiles" "get_materializesAndMemoizes", "get_memoSupersededByNewerSnapshot" "get_staleReturnsValueWithFreshFalse", "get_missReturnsNil" "expireAll_marksOlderStaleKeepingSnapshot", "expireAll_purgeDeletesEntries" + "removeArtifactsIn_removesWhatPutWrote", "removeArtifactsIn_keepsDirWithoutIndex" + "removeArtifactsIn_keepsDirWithUndecodableIndex", "removeArtifactsIn_keepsForeignFiles" + "removeArtifactsIn_missingDirectory" } } diff --git a/modules/l0/DependencyControl/test/legacy-cleanup.moon b/modules/l0/DependencyControl/test/legacy-cleanup.moon new file mode 100644 index 0000000..9fcc328 --- /dev/null +++ b/modules/l0/DependencyControl/test/legacy-cleanup.moon @@ -0,0 +1,114 @@ +-- legacy-cleanup tests: the sweep of the locations DepCtrl wrote to before 0.9.0 removes only files it +-- can identify as its own, skips a location that resolves to the configured one, and totals what each +-- half removed. Recognizing a cache's own files belongs to FileCache and is tested with it; the log +-- half's matching belongs to Logger's trimmer. +-- Runs against real files under a temp base, with the legacy locations pointed at it. +-- Called from test.moon as: (require "…test.legacy-cleanup") basePath, stubHelpers +(basePath, stubHelpers) -> + fileOps = require "l0.DependencyControl.file-ops" + legacyCleanup = require "l0.DependencyControl.legacy-cleanup" + constants = require "l0.DependencyControl.Constants" + dkjson = require "l0.dkjson" + + logger = stubHelpers.makeNullLogger! + originalLegacyPaths = legacyCleanup.__legacyPaths + + -- A fresh legacy/current directory pair per test, with the module's legacy locations pointed at the + -- legacy one. `current` stands in for the configured location and is never written to. + makeDirs = (name) -> + legacy = fileOps.joinPath basePath, "legacyCleanup", name, "legacy" + current = fileOps.joinPath basePath, "legacyCleanup", name, "current" + fileOps.mkdir legacy, false, true + legacyCleanup.__legacyPaths = {log: legacy, cache: legacy} + return legacy, current + + -- A cache entry as FileCache writes it: an index naming its snapshot, plus that snapshot. + writeCacheEntry = (dir, slug, label) -> + snapshot = "#{slug}-#{label}-20260801T101010Z-ABCD.json" + fileOps.writeFile fileOps.joinPath(dir, snapshot), '{"cached": true}', true + meta = {key: "https://example.test/#{label}", cachedAt: 1, expiresAt: 2, latestFile: snapshot} + fileOps.writeFile fileOps.joinPath(dir, "#{slug}.meta.json"), (dkjson.encode meta), true + return snapshot + + -- A log file named the way Logger names DepCtrl's own. + writeLogFile = (dir) -> + path = fileOps.joinPath dir, "2026-08-01-10-10-10-ABCD_#{constants.DEPCTRL_SHORT_NAME}_l0.Test.log" + fileOps.writeFile path, "log line", true + return path + + exists = (path) -> + info = fileOps.getAttributes path, "mode" + return not not (info and info.attr != false) + + { + _description: "legacy-cleanup: removing DepCtrl's own files from its pre-0.9.0 locations." + + -- a cache directory whose index decodes is one DepCtrl wrote, so its entries go + cleanCaches_removesRecognizedEntries: (ut) -> + legacy, current = makeDirs "cacheEntries" + cacheDir = fileOps.joinPath legacy, constants.DEPCTRL_NAMESPACE, "feeds" + fileOps.mkdir cacheDir, false, true + snapshot = writeCacheEntry cacheDir, "0a1b2c3", "someFeed" + + ut\assertEquals legacyCleanup.cleanCaches(current, logger), 2 + ut\assertFalse exists fileOps.joinPath cacheDir, snapshot + ut\assertFalse exists fileOps.joinPath cacheDir, "0a1b2c3.meta.json" + -- the emptied tree goes with its contents + ut\assertFalse exists legacy + + -- wherever Aegisub maps ?state onto ?user the legacy location is the live one; it must be left alone + cleanCaches_skipsWhenLegacyIsCurrent: (ut) -> + legacy = makeDirs "cacheSameDir" + cacheDir = fileOps.joinPath legacy, constants.DEPCTRL_NAMESPACE, "feeds" + fileOps.mkdir cacheDir, false, true + snapshot = writeCacheEntry cacheDir, "0a1b2c3", "someFeed" + + ut\assertEquals legacyCleanup.cleanCaches(legacy, logger), 0 + ut\assertTrue exists fileOps.joinPath cacheDir, snapshot + + -- a location DepCtrl never wrote to costs one probe and reports nothing + cleanCaches_skipsMissingDirectory: (ut) -> + missing = fileOps.joinPath basePath, "legacyCleanup", "neverWritten" + legacyCleanup.__legacyPaths = {log: missing, cache: missing} + ut\assertEquals legacyCleanup.cleanCaches(fileOps.joinPath(basePath, "elsewhere"), logger), 0 + + -- DepCtrl's own log files match its naming; Aegisub's logs shared this directory and must survive + cleanLogs_removesOwnLogFilesOnly: (ut) -> + legacy, current = makeDirs "logs" + ours = writeLogFile legacy + aegisubLog = fileOps.joinPath legacy, "aegisub.json" + fileOps.writeFile aegisubLog, "{}", true + + ut\assertEquals legacyCleanup.cleanLogs(current, logger), 1 + ut\assertFalse exists ours + ut\assertTrue exists aegisubLog + -- a directory still holding someone else's files is not ours to remove + ut\assertTrue exists legacy + + cleanLogs_skipsWhenLegacyIsCurrent: (ut) -> + legacy = makeDirs "logsSameDir" + ours = writeLogFile legacy + + ut\assertEquals legacyCleanup.cleanLogs(legacy, logger), 0 + ut\assertTrue exists ours + + -- run sweeps both locations and totals what each removed + run_totalsBothLocations: (ut) -> + legacy, current = makeDirs "run" + cacheDir = fileOps.joinPath legacy, constants.DEPCTRL_NAMESPACE, "feeds" + fileOps.mkdir cacheDir, false, true + writeCacheEntry cacheDir, "0a1b2c3", "someFeed" + writeLogFile legacy + + ut\assertEquals legacyCleanup.run({log: current, cache: current}, logger), 3 + + -- the legacy locations are module state, so they must not stay pointed at the temp base + _teardown: -> legacyCleanup.__legacyPaths = originalLegacyPaths + + _order: { + "cleanCaches_removesRecognizedEntries" + "cleanCaches_skipsWhenLegacyIsCurrent", "cleanCaches_skipsMissingDirectory" + "cleanLogs_removesOwnLogFilesOnly", "cleanLogs_skipsWhenLegacyIsCurrent" + "run_totalsBothLocations" + } + } From 60bdb0adab5b6276050bf8967bd736e48724b3c1 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 2 Aug 2026 13:44:42 +0200 Subject: [PATCH 4/7] docs(style): favour single-line postfix forms over one-statement blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MS1 already lists postfix conditionals and loop modifiers among the idioms to prefer, but says nothing about when a block is the wrong shape for a body that is one statement. MS5 states the test — it has to fit the line and stay readable — and shows the `when` filter collapsing a guarded accumulation that would otherwise carry an inner `if`. PathOps.isTokenSupported had the memo lookup's early return in a block; it folds into a postfix `unless`. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/STYLE.md b/STYLE.md index bb43755..6fb625e 100644 --- a/STYLE.md +++ b/STYLE.md @@ -20,6 +20,16 @@ Scope: this file is about the code. Contributor and agent *workflow* (verifying - **MS2.** `or=` is a statement, not an expression: a method whose whole body is `@field or= value` returns `nil`. You **MUST** add an explicit `return @field` after a lazy-init `or=` when the caller expects a value. - **MS3.** `@@field arg` / `@field arg` compile to *colon* calls (`self.__class:field(arg)` / `self:field(arg)`), passing an implicit first argument. When the field holds a constructor or plain function you want to call plainly, you **MUST** write `@@.field arg` / `@.field arg` (or bind it to a local first). - **MS4.** A constructor's return value is discarded — `Cls(...)` always yields the instance, so a `return nil, err` inside `new` is dead code. Validation a caller can trip **MUST** happen before or around construction (in the factory), not inside `new`. +- **MS5.** A loop or conditional whose body is one statement **SHOULD** be written as a single line with postfix modifiers (MS1), as long as it fits the line and stays readable. `when` filters a postfix loop, so a guarded accumulation collapses to one line without an inner `if`. Keep the block form when the line would have to wrap, when the body does more than one thing, or when a long condition buries the statement it guards. + + ```moon + -- GOOD — the whole loop, filter included, reads as one statement: + removed += 1 for path in *indexes when fileOps.remove path + + -- BAD — a block wrapped around a single guarded statement that fits on one line: + for path in *indexes + removed += 1 if fileOps.remove path + ``` ## FFI — LuaJIT C bindings From 0eac8d0349b151b6fcf4c32cca693a9706f562b2 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 2 Aug 2026 14:15:41 +0200 Subject: [PATCH 5/7] refactor(FileCache): single-source the snapshot path, lock identity and defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving an index entry's snapshot — joining it onto the cache directory and confirming the file is still there — was written out in both getFile and get, so the rule for where a snapshot lives had two sites. A private __snapshotPath holds it once; get keeps its own index read, so nothing pays for a second decode. The lock namespace spelled out l0.DependencyControl, which constants already carries, and the lock holder name repeated the class name a third time. Both derive now, so renaming the class can't leave a stale identity behind (ID1). The FileCacheOptions annotations restated the 3600 and 50 that defaultMaxAge and defaultMaxFiles define twelve lines below, with nothing keeping the two in step; they point at the fields instead. Co-Authored-By: Claude Opus 5 (1M context) --- DependencyControl.json | 2 +- modules/l0/DependencyControl/FileCache.moon | 42 +++++++++++---------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/DependencyControl.json b/DependencyControl.json index a32ec9d..37d897c 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -171,7 +171,7 @@ { "name": "/FileCache.moon", "url": "@{fileBaseUrl}", - "sha1": "E9078D98329F984499A6D68AE79359F962AC9A03" + "sha1": "B647B4FE736924AF8D0CD2C55096E11F1711BD4E" }, { "name": "/FeedInventory.moon", diff --git a/modules/l0/DependencyControl/FileCache.moon b/modules/l0/DependencyControl/FileCache.moon index 404227a..a5d2a98 100644 --- a/modules/l0/DependencyControl/FileCache.moon +++ b/modules/l0/DependencyControl/FileCache.moon @@ -8,18 +8,21 @@ dkjson = require "l0.dkjson" defaultLogger = Logger fileBaseName: "#{constants.DEPCTRL_SHORT_NAME}.FileCache" -LOCK_NAMESPACE = "l0.DependencyControl.FileCache" -- Global-lock namespace for serializing cache writes +LOCK_NAMESPACE = "#{constants.DEPCTRL_NAMESPACE}.FileCache" -- Global-lock namespace for serializing cache writes LOCK_TIMEOUT = 5000 -- ms to wait for the write lock before skipping the write +SLUG_LENGTH = 7 -- hex digits of a key's digest kept as the slug every one of its files is named with +SLUG_PATTERN = "%x"\rep SLUG_LENGTH +META_FILE_NAME_PATTERN = "^#{SLUG_PATTERN}%.meta%.json$" +SNAPSHOT_FILE_NAME_PATTERN = "^#{SLUG_PATTERN}%-.*%-%d+T%d+Z%-%x%x%x%x%.json$" +SLUG_CAPTURE_PATTERN = "^(#{SLUG_PATTERN})" + -- 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" -SLUG_LENGTH = 7 -- hex digits of a key's digest kept as the slug every one of its files is named with -SLUG_PATTERN = "%x"\rep SLUG_LENGTH - -- The slug of a cache key. Deterministic per key (sensitive to its exact bytes), matching the DepCtrl -- Browser's feed-URL slug convention. keySlug = (key) -> Hash.getDigest(Hash.HashType.Sha1, key)\sub 1, SLUG_LENGTH @@ -27,12 +30,6 @@ keySlug = (key) -> Hash.getDigest(Hash.HashType.Sha1, key)\sub 1, SLUG_LENGTH -- The embedded UTC timestamp of a snapshot file name, for chronological ordering ("" when absent). snapshotStamp = (fileName) -> fileName\match "(%d+T%d+Z)" or "" --- The file names this cache writes, as the patterns that recognize them again. __metaPath and __write --- compose those names, so these have to stay in step with them. -META_FILE_NAME_PATTERN = "^#{SLUG_PATTERN}%.meta%.json$" -SNAPSHOT_FILE_NAME_PATTERN = "^#{SLUG_PATTERN}%-.*%-%d+T%d+Z%-%x%x%x%x%.json$" -SLUG_CAPTURE_PATTERN = "^(#{SLUG_PATTERN})" - ---Reads and decodes a cache index (meta) JSON file. ---@param path string ---@return FileCacheMeta? meta Nil when the file is absent or doesn't decode to a table. @@ -63,8 +60,8 @@ resolveDir = (basePath, namespace, name) -> "#{pathOps.decode basePath}/#{namesp ---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 maxAge? integer Default entry lifetime in seconds, used when a put doesn't set its own; defaults to the class's `defaultMaxAge`. +---@field maxFiles? integer Snapshot files retained per cache before the oldest are trimmed; defaults to the class's `defaultMaxFiles`. ---@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. @@ -171,6 +168,15 @@ class FileCache return false if @__staleBefore and meta.cachedAt and meta.cachedAt < @__staleBefore @.now! < meta.expiresAt + ---Resolves the snapshot an index entry points at, confirming the file is still there. + ---@private + ---@param meta FileCacheMeta An index entry carrying a `latestFile`. + ---@return string? path The snapshot's path, or nil when the file it names is gone. + __getSnapshotPath: (meta) => + path = fileOps.joinPath @cacheDir, meta.latestFile + info = fileOps.getAttributes path, "mode" + info and info.attr == "file" and path or nil + ---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 @@ -179,9 +185,8 @@ class FileCache getFile: (key) => meta = @getMeta key return nil unless meta and meta.latestFile - path = fileOps.joinPath @cacheDir, meta.latestFile - info = fileOps.getAttributes path, "mode" - return nil, meta unless info and info.attr == "file" + path = @__getSnapshotPath meta + return nil, meta unless path return path, meta ---Returns the deserialized latest snapshot for a key, served from the in-memory L1 memo when it still @@ -200,9 +205,8 @@ class FileCache 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 - info = fileOps.getAttributes path, "mode" - content = info and info.attr == "file" and fileOps.readFile path + path = @__getSnapshotPath meta + content = path and fileOps.readFile path return nil, meta, fresh unless content value = @.__deserialize content @@ -253,7 +257,7 @@ class FileCache namespace: LOCK_NAMESPACE resource: @cacheDir scope: Lock.Scope.Global - holderName: "FileCache" + holderName: @@__name logger: @logger timeout: LOCK_TIMEOUT }, -> @__write key, content, label, expiresAfter From 63090c8280f24bd6d3656a07fe2743e7f54350f0 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 2 Aug 2026 15:09:20 +0200 Subject: [PATCH 6/7] refactor(PathOps)!: give resolveFullPath an absolute directory and move callers over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateFullPath split a "device" off the front of the directory, leaving `dir` relative to it: `/usr/local/bin` came back as `/usr` plus `/local`, and `C:\Users\foo\bar.txt` as `C:\` plus `Users\foo`. Neither is a directory a caller can use, which is why the one consumer of the pair, FileOps.mkdir, concatenated them straight back together. It now reports the whole directory, which frees the second return to be an error and nothing else — it previously carried the device on success, and getAttributes already misread it as an error under the name `dev`. The name went with the shape. "Validate" described only the failure half, while every call site wants the canonical absolute path it produces, so the PathOps member is resolveFullPath. FileOps.validateFullPath keeps the pre-0.9 four values for callers written against it, deprecated, splitting the root back off through PathOps._getPathRoot; the same split backs the pre-0.7 `attributes` shim. FileOpsAttributesInfo drops `dev` outright and its `dir` is absolute: it shipped in 0.7.0 nine days ago and nothing outside FileOps read it. Internal callers move off the deprecated re-exports at the same time, since deprecating a function and then calling it from every module advertises the opposite of what the deprecation says. Test doubles follow their subjects, and their module-name constants gained the word boundary they were missing — FILEOPS only passed the spell check because cspell's AWS dictionary carries it. Co-Authored-By: Claude Opus 5 (1M context) --- .cspell/project.txt | 1 + DependencyControl.json | 50 ++++---- depctrl.lua | 14 ++- .../l0/DependencyControl/ConfigHandler.moon | 5 +- modules/l0/DependencyControl/FileCache.moon | 14 +-- modules/l0/DependencyControl/FileLock.moon | 3 +- modules/l0/DependencyControl/JsonSchema.moon | 3 +- .../l0/DependencyControl/PackageRecord.moon | 4 +- modules/l0/DependencyControl/UpdateFeed.moon | 9 +- modules/l0/DependencyControl/UpdateTask.moon | 3 +- modules/l0/DependencyControl/ZipArchiver.moon | 3 +- modules/l0/DependencyControl/domain.moon | 4 +- modules/l0/DependencyControl/file-ops.moon | 33 +++--- modules/l0/DependencyControl/path-ops.moon | 59 +++++----- .../DependencyControl/test/ConfigHandler.moon | 7 +- .../l0/DependencyControl/test/FeedLoader.moon | 3 +- .../l0/DependencyControl/test/FileCache.moon | 39 ++++--- .../l0/DependencyControl/test/JsonSchema.moon | 3 +- .../DependencyControl/test/PackageRecord.moon | 39 ++++--- .../l0/DependencyControl/test/UpdateFeed.moon | 69 +++++------ .../l0/DependencyControl/test/UpdateTask.moon | 15 +-- .../DependencyControl/test/ZipArchiver.moon | 3 +- .../l0/DependencyControl/test/file-ops.moon | 14 ++- .../helpers/MockHttpServerController.moon | 11 +- .../test/legacy-cleanup.moon | 29 ++--- .../l0/DependencyControl/test/path-ops.moon | 110 ++++++++++-------- 26 files changed, 299 insertions(+), 248 deletions(-) diff --git a/.cspell/project.txt b/.cspell/project.txt index 71a7ec8..c43a154 100644 --- a/.cspell/project.txt +++ b/.cspell/project.txt @@ -91,6 +91,7 @@ myaamori NTFS nullary parentheticals +PATHOPS pcre pegasus petzku diff --git a/DependencyControl.json b/DependencyControl.json index 37d897c..bbf99ee 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -141,7 +141,7 @@ { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}", - "sha1": "6C8ACA7DD2BBA8AD22AB7E488F918297B820EDF9" + "sha1": "5849A36B066EFF6FFF9F63B4C9397DB9A82E9FEE" }, { "name": "/ConfigView.moon", @@ -171,7 +171,7 @@ { "name": "/FileCache.moon", "url": "@{fileBaseUrl}", - "sha1": "B647B4FE736924AF8D0CD2C55096E11F1711BD4E" + "sha1": "C67FDDD66493F3F197D44B057D56A7B8554F11F2" }, { "name": "/FeedInventory.moon", @@ -191,12 +191,12 @@ { "name": "/FileLock.moon", "url": "@{fileBaseUrl}", - "sha1": "28F79DB790CF4746441B6127A8953708D1A52501" + "sha1": "60A9B7FF96E0C7068EC7EE8BB72C27C2B34220EF" }, { "name": "/file-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "C325CC4B19EE226CAB1D7D06B8229B514DD014E6" + "sha1": "A1835190664A941EFA21A7CF870460B4780F4BAC" }, { "name": "/FileOps.moon", @@ -217,7 +217,7 @@ { "name": "/JsonSchema.moon", "url": "@{fileBaseUrl}", - "sha1": "2707158CB66573CEA10465C21E3AB42708E482F7" + "sha1": "0342242069D983F60D572626034199C89FBEE955" }, { "name": "/Lock.moon", @@ -247,7 +247,7 @@ { "name": "/PackageRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "C77D649499C6E764C6AF07DE661EDA6E8B4C5729" + "sha1": "F31022977767283B00F08C008A3D85199FC670D4" }, { "name": "/ScriptTargetFilter.moon", @@ -282,12 +282,12 @@ { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}", - "sha1": "521AD8915BF09A02167ACDF73CE4C39E928F504A" + "sha1": "3002D296BB4670965633E0A4BAF02F80D743E323" }, { "name": "/UpdateTask.moon", "url": "@{fileBaseUrl}", - "sha1": "D7296244D4B9DAEAD0463CF6FE699AAF5CDF39A8" + "sha1": "72015BAB0918AFFFF5ED6340B13D272414FB66E3" }, { "name": "/Updater.moon", @@ -297,7 +297,7 @@ { "name": "/ZipArchiver.moon", "url": "@{fileBaseUrl}", - "sha1": "CC7A19E78B8B87DB70177A4A47CB10497232DF26" + "sha1": "EAD3E888DAA28DA5F4E00A251BB4740CE9CA87B8" }, { "name": "/helpers/ffi-posix.moon", @@ -362,7 +362,7 @@ { "name": "/domain.moon", "url": "@{fileBaseUrl}", - "sha1": "12BED2BD17AA6364130D30CF0BD20630E9A44597" + "sha1": "D28D4A012D7E6B89E1946BEB8BC46DCD7F52D4FA" }, { "name": "/environment.moon", @@ -377,7 +377,7 @@ { "name": "/path-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "26C8E3A430C7F6D5710FE775AA583C1BE855431E" + "sha1": "BC9E563718EC70284B404D01753F3452549BDCF6" }, { "name": "/legacy-cleanup.moon", @@ -399,7 +399,7 @@ { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}", - "sha1": "D0F735761CAC12540F7841DCF8A80AB6C20ECCE5", + "sha1": "C0DCB8FDAE3A53F27E4D7742ADA8660B9ACECF8C", "type": "test" }, { @@ -429,7 +429,7 @@ { "name": "/FileCache.moon", "url": "@{fileBaseUrl}", - "sha1": "90DF6DEEEFA062535E0F080681E2B31C9A34B60E", + "sha1": "C2DB39F83ADB561A5C92CCCBC2D518064551DB76", "type": "test" }, { @@ -465,7 +465,7 @@ { "name": "/file-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "588E50609A52399576D74B8090131B071CBA6823", + "sha1": "A503A1CAA3B0FAFA2D62BB4ABA651A3BF7C6F048", "type": "test" }, { @@ -483,13 +483,13 @@ { "name": "/JsonSchema.moon", "url": "@{fileBaseUrl}", - "sha1": "125E4930956E7DCA578C2F88EDB4B56C14A031C6", + "sha1": "2B3998285534B6C9EAC9B36BAA8CEF5E0ABF7E08", "type": "test" }, { "name": "/Lock.moon", "url": "@{fileBaseUrl}", - "sha1": "26B3D5CA9E6D91C6F4D9411F093C26B3AE58FA96", + "sha1": "525628C362F564035CB0C866761FDC6CE7B52404", "type": "test" }, { @@ -525,7 +525,7 @@ { "name": "/PackageRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "9AB3488762804B65BF48CE33C44BA99628DF3F9D", + "sha1": "C6BBD7319C511C0A8C13F6F4440A14DD277D772E", "type": "test" }, { @@ -561,13 +561,13 @@ { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}", - "sha1": "4A3CE78253A52C70D6C28394BF724F9D0552DB0E", + "sha1": "3C72B65917FC447B97ACA1C76596AE0F12C32F5B", "type": "test" }, { "name": "/UpdateTask.moon", "url": "@{fileBaseUrl}", - "sha1": "5CA6FC899341677F702788F543373C8F3EDCFAA0", + "sha1": "C1E70BD3720843D0CC163A30BD30FF8FA1B16728", "type": "test" }, { @@ -579,7 +579,7 @@ { "name": "/ZipArchiver.moon", "url": "@{fileBaseUrl}", - "sha1": "508996E6E08DFDA179327AFD70219344E21BD66E", + "sha1": "2D0AC676445C6E5E894B377981B4849DBC2EA9BF", "type": "test" }, { @@ -603,7 +603,7 @@ { "name": "/helpers/MockHttpServerController.moon", "url": "@{fileBaseUrl}", - "sha1": "F4F84B3E954CB41DF6157ECF623B36DA3FF8EEBD", + "sha1": "AF0D432D6DD397460B7BC6A22E7C3BD978EC89EC", "type": "test" }, { @@ -615,7 +615,7 @@ { "name": "/FeedLoader.moon", "url": "@{fileBaseUrl}", - "sha1": "D24848390EC92F0FCD088ECFDA62C67D12700F8F", + "sha1": "E3179B0B1C3E65C42BC44D643953AFFCE62F1F66", "type": "test" }, { @@ -663,13 +663,13 @@ { "name": "/path-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "69E5588A097CFB08CF8FF7A63555CBD6ECFDF6E2", + "sha1": "FE13A698E66BB4C1C7BADF6867506945C4DFD30A", "type": "test" }, { "name": "/legacy-cleanup.moon", "url": "@{fileBaseUrl}", - "sha1": "DAF3711D24799E032C11D0BF2B7926D518C540C8", + "sha1": "E178DE5AB6215648A1EC74D6BF85DD876D9FF50E", "type": "test" } ], @@ -807,7 +807,7 @@ ], "0.9.0": [ "change: Log files and on-disk caches now follow the XDG Base Directory specification, moving to `?state/log` and `?local/cache`; an Aegisub build that lacks the newer `?state` path token keeps writing logs to `?user/log`, so a path setting naming `?state` works on every version. Whatever an earlier release left behind in the old locations is cleaned up on startup, and only files DependencyControl can identify as its own are removed, so Aegisub's own logs stay untouched.", - "feat: Added `DependencyControl.PathOps`, a filesystem-free home for path composition, validation, and Aegisub path-token resolution. `joinPath`, `validateFullPath`, `pathSegments`, `getTempDir` and the path constants moved there from `FileOps`, which still re-exports each under its existing name." + "refactor!: Added `DependencyControl.PathOps`, a filesystem-free home for path composition, validation, and Aegisub path-token resolution. `joinPath()`, `pathSegments()`, `getTempDir()` as well as the path constants moved there from `FileOps`, leaving behind re-exports for backwards compatibility. `FileOps.validateFullPath` is superseded by `PathOps.resolveFullPath`, which reports the absolute directory rather than a device plus a fragment relative to it. `FileOps.getAttributes` likewise drops the `dev` field from its info table, and its `dir` is now absolute (technically a breaking change, but the method was only added in v0.7.0, so it's unlikely to be in active use, yet)." ] } }, diff --git a/depctrl.lua b/depctrl.lua index 1b8826c..06a67dc 100644 --- a/depctrl.lua +++ b/depctrl.lua @@ -329,6 +329,7 @@ if args.command == "test" then local DepCtrl = setupDepCtrl("tests") local FileOps = require "l0.DependencyControl.file-ops" + local PathOps = require "l0.DependencyControl.path-ops" local feedPath = resolveAbsPath(args.feed) local feed = loadFeed(feedPath) @@ -382,7 +383,7 @@ if args.command == "test" then end end - local reportPath = FileOps.joinPath(reportDir, ns .. ".json") + local reportPath = PathOps.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")) @@ -410,10 +411,11 @@ elseif args.command == "bundle" then setupDepCtrl("bundle") local FileOps = require "l0.DependencyControl.file-ops" + local PathOps = require "l0.DependencyControl.path-ops" local ZipArchiver = require "l0.DependencyControl.ZipArchiver" local GitRepository = require "l0.DependencyControl.GitRepository" - local canonicalOut, outErr = FileOps.validateFullPath({ outputDir }, false, lfs.currentdir()) + local canonicalOut, outErr = PathOps.resolveFullPath({ outputDir }, false, lfs.currentdir()) if not canonicalOut then io.stderr:write("Error resolving output directory '" .. outputDir .. "': " .. tostring(outErr) .. "\n") os.exit(1) @@ -561,6 +563,7 @@ elseif args.command == "serve-updates" then local UpdateFeed = require "l0.DependencyControl.UpdateFeed" local FileOps = require "l0.DependencyControl.file-ops" + local PathOps = require "l0.DependencyControl.path-ops" local json = require "l0.dkjson" local socket = require "socket" local copas = require "copas" @@ -571,7 +574,7 @@ elseif args.command == "serve-updates" then -- feed an older client can read. local sourceFeed = UpdateFeed(nil, false, feedPath) registerFeedSearcher(sourceFeed) - local refreshedPath = FileOps.joinPath(assert(FileOps.createTempDir()), "refreshed.json") + local refreshedPath = PathOps.joinPath(assert(FileOps.createTempDir()), "refreshed.json") local refreshed, refreshErr = sourceFeed:updateFeed({ outPath = refreshedPath }) if not refreshed then io.stderr:write("serve-updates: couldn't refresh the feed: " .. tostring(refreshErr) .. "\n") @@ -582,7 +585,7 @@ elseif args.command == "serve-updates" then -- Aegisub can be tested against this feed without merging channels by hand first. local feedToServe = refreshedPath if args.serve_channel then - local mergedPath = FileOps.joinPath(dirname(refreshedPath), "merged.json") + local mergedPath = PathOps.joinPath(dirname(refreshedPath), "merged.json") local merged, mergeErr = UpdateFeed(nil, false, refreshedPath):mergeChannels(UpdateFeed(nil, false, refreshedPath), { from = args.from_channel, to = { args.serve_channel }, @@ -843,6 +846,7 @@ elseif args.command == "generate-types" then setupDepCtrl("generate-types") local FileOps = require "l0.DependencyControl.file-ops" + local Domain = require "l0.DependencyControl.domain" local feed = loadFeed(feedPath) local filter = buildFilter(args) @@ -864,7 +868,7 @@ elseif args.command == "generate-types" then 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") + local outPath = Domain.getNamespacedPath(outDir, def.requireId, ".d.lua") FileOps.mkdir(outPath, true, true) local ok, writeErr = FileOps.writeFile(outPath, def.text, true) if ok then diff --git a/modules/l0/DependencyControl/ConfigHandler.moon b/modules/l0/DependencyControl/ConfigHandler.moon index b6bb00d..ffae3a2 100644 --- a/modules/l0/DependencyControl/ConfigHandler.moon +++ b/modules/l0/DependencyControl/ConfigHandler.moon @@ -1,6 +1,7 @@ dkjson = require "l0.dkjson" -- vendored dkjson directly, for its Prettier `indentMode` and `null` sentinel (neither guaranteed via a user-supplied json) constants = require "l0.DependencyControl.Constants" fileOps = require "l0.DependencyControl.file-ops" +pathOps = require "l0.DependencyControl.path-ops" Logger = require "l0.DependencyControl.Logger" Lock = require "l0.DependencyControl.Lock" ConfigView = require "l0.DependencyControl.ConfigView" @@ -86,7 +87,7 @@ class ConfigHandler @get = (filePath, logger = defaultLogger, 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 + path, msg = pathOps.resolveFullPath filePath, true return nil, msgs.new.badPath\format filePath, msg unless path return @@handlers[path] if @@handlers[path] @@ -130,7 +131,7 @@ class ConfigHandler @__targetSchemaId = schemaOpts.schemaId @__migrate = schemaOpts.migrate if filePath - path, msg = fileOps.validateFullPath filePath, true + path, msg = pathOps.resolveFullPath filePath, true @logger\assert path, msgs.new.badPath, filePath, msg @filePath = path -- config files are shared across concurrent Aegisub instances, so the lock diff --git a/modules/l0/DependencyControl/FileCache.moon b/modules/l0/DependencyControl/FileCache.moon index a5d2a98..d2842f9 100644 --- a/modules/l0/DependencyControl/FileCache.moon +++ b/modules/l0/DependencyControl/FileCache.moon @@ -110,7 +110,7 @@ class FileCache removed, indexes, snapshots = 0, {}, {} for entry in *entries - path = fileOps.joinPath dir, entry + path = pathOps.joinPath dir, entry info = fileOps.getAttributes path, "mode" continue unless info if info.attr == "directory" @@ -145,7 +145,7 @@ class FileCache ---@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" + __metaPath: (key) => pathOps.joinPath @cacheDir, "#{keySlug key}.meta.json" ---Reads and decodes a cache index (meta) JSON file. ---@private @@ -173,7 +173,7 @@ class FileCache ---@param meta FileCacheMeta An index entry carrying a `latestFile`. ---@return string? path The snapshot's path, or nil when the file it names is gone. __getSnapshotPath: (meta) => - path = fileOps.joinPath @cacheDir, meta.latestFile + path = pathOps.joinPath @cacheDir, meta.latestFile info = fileOps.getAttributes path, "mode" info and info.attr == "file" and path or nil @@ -230,7 +230,7 @@ class FileCache expiredSlugs = {} for file in *files continue unless file\match META_FILE_NAME_PATTERN - meta = @__readMeta fileOps.joinPath @cacheDir, file + meta = @__readMeta pathOps.joinPath @cacheDir, file continue unless meta and meta.cachedAt and meta.cachedAt < before expiredSlugs[keySlug meta.key] = true @__l1[meta.key] = nil @@ -238,7 +238,7 @@ class FileCache -- every file (snapshot or index) is named with its key's slug prefix, so one pass removes both for file in *files slug = file\match SLUG_CAPTURE_PATTERN - fileOps.remove fileOps.joinPath @cacheDir, file if slug and expiredSlugs[slug] + fileOps.remove pathOps.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 `-