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 bec095d..1e9142f 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", @@ -138,7 +141,7 @@ { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}", - "sha1": "6C8ACA7DD2BBA8AD22AB7E488F918297B820EDF9" + "sha1": "5849A36B066EFF6FFF9F63B4C9397DB9A82E9FEE" }, { "name": "/ConfigView.moon", @@ -168,7 +171,7 @@ { "name": "/FileCache.moon", "url": "@{fileBaseUrl}", - "sha1": "600F604B26D4C74D7FDC1F5BEA37512A49AF983F" + "sha1": "C67FDDD66493F3F197D44B057D56A7B8554F11F2" }, { "name": "/FeedInventory.moon", @@ -188,12 +191,12 @@ { "name": "/FileLock.moon", "url": "@{fileBaseUrl}", - "sha1": "28F79DB790CF4746441B6127A8953708D1A52501" + "sha1": "60A9B7FF96E0C7068EC7EE8BB72C27C2B34220EF" }, { "name": "/file-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "2FB92DBDC1AA861B7D5A859B165CE71C39E8FBD0" + "sha1": "A1835190664A941EFA21A7CF870460B4780F4BAC" }, { "name": "/FileOps.moon", @@ -214,7 +217,7 @@ { "name": "/JsonSchema.moon", "url": "@{fileBaseUrl}", - "sha1": "2707158CB66573CEA10465C21E3AB42708E482F7" + "sha1": "0342242069D983F60D572626034199C89FBEE955" }, { "name": "/Lock.moon", @@ -224,7 +227,7 @@ { "name": "/Logger.moon", "url": "@{fileBaseUrl}", - "sha1": "CBFC4B9C5699D3606D7AEE92137DA91DEC1B4EBD" + "sha1": "C9C32774B31904ED7B90864B4766802C754EAB3B" }, { "name": "/ModuleLoader.moon", @@ -244,7 +247,7 @@ { "name": "/PackageRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "8BF956EEF2223D75B42FEBAE3BE21E2FC2A1CDA7" + "sha1": "F31022977767283B00F08C008A3D85199FC670D4" }, { "name": "/ScriptTargetFilter.moon", @@ -279,12 +282,12 @@ { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}", - "sha1": "521AD8915BF09A02167ACDF73CE4C39E928F504A" + "sha1": "3002D296BB4670965633E0A4BAF02F80D743E323" }, { "name": "/UpdateTask.moon", "url": "@{fileBaseUrl}", - "sha1": "D7296244D4B9DAEAD0463CF6FE699AAF5CDF39A8" + "sha1": "72015BAB0918AFFFF5ED6340B13D272414FB66E3" }, { "name": "/Updater.moon", @@ -294,7 +297,7 @@ { "name": "/ZipArchiver.moon", "url": "@{fileBaseUrl}", - "sha1": "CC7A19E78B8B87DB70177A4A47CB10497232DF26" + "sha1": "EAD3E888DAA28DA5F4E00A251BB4740CE9CA87B8" }, { "name": "/helpers/ffi-posix.moon", @@ -344,7 +347,7 @@ { "name": "/config-schema.moon", "url": "@{fileBaseUrl}", - "sha1": "CC74ACBE7A7CD8761AD714564F3805AC96F68067" + "sha1": "F6FD0E470370511689CA5B01F28D8BC3BCD88F39" }, { "name": "/Finalizer.moon", @@ -359,7 +362,7 @@ { "name": "/domain.moon", "url": "@{fileBaseUrl}", - "sha1": "C5D9BC686DFA4A85A28EE6E2E392B1E012A83909" + "sha1": "D28D4A012D7E6B89E1946BEB8BC46DCD7F52D4FA" }, { "name": "/environment.moon", @@ -371,10 +374,20 @@ "url": "@{fileBaseUrl}", "sha1": "FA8307BC15AD2FEC1535F9B862E1D92C2A3A1AB9" }, + { + "name": "/path-ops.moon", + "url": "@{fileBaseUrl}", + "sha1": "BC9E563718EC70284B404D01753F3452549BDCF6" + }, + { + "name": "/legacy-cleanup.moon", + "url": "@{fileBaseUrl}", + "sha1": "2838C5668DD417F9581B51DA9426857DD89B5F4F" + }, { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "1FE9D211C256E84F4C0F95BBA10659A1F7B9A0C7", + "sha1": "B351208EA529FD6A6D58D6A8DE1780B872D3FDE0", "type": "test" }, { @@ -386,7 +399,7 @@ { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}", - "sha1": "D0F735761CAC12540F7841DCF8A80AB6C20ECCE5", + "sha1": "C0DCB8FDAE3A53F27E4D7742ADA8660B9ACECF8C", "type": "test" }, { @@ -416,7 +429,7 @@ { "name": "/FileCache.moon", "url": "@{fileBaseUrl}", - "sha1": "633106FDCDD0AB81CFC2BB85725B9A7B0077AF83", + "sha1": "C2DB39F83ADB561A5C92CCCBC2D518064551DB76", "type": "test" }, { @@ -452,7 +465,7 @@ { "name": "/file-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "AB2BBDDFEDBB158963CFDFAE196C584E105598CC", + "sha1": "A503A1CAA3B0FAFA2D62BB4ABA651A3BF7C6F048", "type": "test" }, { @@ -470,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" }, { @@ -512,7 +525,7 @@ { "name": "/PackageRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "9AB3488762804B65BF48CE33C44BA99628DF3F9D", + "sha1": "C6BBD7319C511C0A8C13F6F4440A14DD277D772E", "type": "test" }, { @@ -548,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" }, { @@ -566,7 +579,7 @@ { "name": "/ZipArchiver.moon", "url": "@{fileBaseUrl}", - "sha1": "508996E6E08DFDA179327AFD70219344E21BD66E", + "sha1": "2D0AC676445C6E5E894B377981B4849DBC2EA9BF", "type": "test" }, { @@ -590,7 +603,7 @@ { "name": "/helpers/MockHttpServerController.moon", "url": "@{fileBaseUrl}", - "sha1": "F4F84B3E954CB41DF6157ECF623B36DA3FF8EEBD", + "sha1": "AF0D432D6DD397460B7BC6A22E7C3BD978EC89EC", "type": "test" }, { @@ -602,13 +615,13 @@ { "name": "/FeedLoader.moon", "url": "@{fileBaseUrl}", - "sha1": "D24848390EC92F0FCD088ECFDA62C67D12700F8F", + "sha1": "E3179B0B1C3E65C42BC44D643953AFFCE62F1F66", "type": "test" }, { "name": "/config-schema.moon", "url": "@{fileBaseUrl}", - "sha1": "C65BC75E4ACF11F2CA174255CB6792C3C4FAD7A7", + "sha1": "A1C2BE15B2B155BFDD4BC9BC04E14D18F7D3ADC4", "type": "test" }, { @@ -632,13 +645,13 @@ { "name": "/domain.moon", "url": "@{fileBaseUrl}", - "sha1": "2406064A5B8F234ECB69E9C60F5FC1A5E9B7657A", + "sha1": "6CEFB94846D96267E745F0F8A06D37147E234E2D", "type": "test" }, { "name": "/utils.moon", "url": "@{fileBaseUrl}", - "sha1": "F78238492CB05F9F1568C14DE363A83CE1CAB6D8", + "sha1": "5107E471C45705B7F6ABD6A1EA5F16DFDEBBAA46", "type": "test" }, { @@ -646,6 +659,18 @@ "url": "@{fileBaseUrl}", "sha1": "1889EFB487A85EF9CD9838312B36CAB97D332119", "type": "test" + }, + { + "name": "/path-ops.moon", + "url": "@{fileBaseUrl}", + "sha1": "FE13A698E66BB4C1C7BADF6867506945C4DFD30A", + "type": "test" + }, + { + "name": "/legacy-cleanup.moon", + "url": "@{fileBaseUrl}", + "sha1": "E178DE5AB6215648A1EC74D6BF85DD876D9FF50E", + "type": "test" } ], "requiredModules": [ @@ -779,6 +804,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. 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.", + "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/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/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 diff --git a/depctrl.lua b/depctrl.lua index 9b4ea4d..06a67dc 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). @@ -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/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/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 26e32fa..d2842f9 100644 --- a/modules/l0/DependencyControl/FileCache.moon +++ b/modules/l0/DependencyControl/FileCache.moon @@ -3,29 +3,53 @@ 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" -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" --- 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 +-- 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 "" +---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) -> "#{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 @@ -36,8 +60,8 @@ resolveDir = (basePath, namespace, name) -> "#{aegisub.decode_path basePath}/#{n ---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. @@ -61,7 +85,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 +98,37 @@ 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. + ---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 = pathOps.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"). ---@param opts? FileCacheOptions Defaults for entry lifetime, retention, logging, clock, and the L1 codec. @@ -91,18 +145,13 @@ 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 ---@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 @@ -119,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 = pathOps.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 @@ -127,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 @@ -148,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 @@ -173,16 +229,16 @@ 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$" - meta = @__readMeta fileOps.joinPath @cacheDir, file + continue unless file\match META_FILE_NAME_PATTERN + 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 - -- 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)" - fileOps.remove fileOps.joinPath @cacheDir, file if slug and expiredSlugs[slug] + slug = file\match SLUG_CAPTURE_PATTERN + 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 `-