Skip to content
Open
1 change: 1 addition & 0 deletions .cspell/project.txt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ myaamori
NTFS
nullary
parentheticals
PATHOPS
pcre
pegasus
petzku
Expand Down
65 changes: 47 additions & 18 deletions DependencyControl.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@
"url": "@{baseUrl}#@{namespace}",
"channels": {
"main": {
"version": "0.9.0",
"released": "2026-08-20",
"version": "0.10.0",
"released": null,
"default": true,
"files": [
{
Expand Down Expand Up @@ -113,6 +113,9 @@
"0.9.0": [
"feat: A new Package Source Configuration macro lets you choose where a package's next update comes from — the source kind, channel, how firmly the choice is kept, and either a known feed, a custom feed URL, or a providing module.",
"fix: The Install, Uninstall and Update pickers now show each package's update channel beside its version, which was missing from the lists in most circumstances."
],
"0.10.0": [
"change: The Global Configuration dialog now notes that the log directory's `?state` token falls back to `?user`, and the cache directory's `?cache` token to `?local`, on an Aegisub build without them."
]
}
}
Expand All @@ -125,8 +128,8 @@
"url": "@{baseUrl}#@{namespace}",
"channels": {
"main": {
"version": "0.9.1",
"released": "2026-10-13",
"version": "0.10.0",
"released": null,
"default": true,
"files": [
{
Expand Down Expand Up @@ -172,7 +175,7 @@
{
"name": "/FileCache.moon",
"url": "@{fileBaseUrl}",
"sha1": "600F604B26D4C74D7FDC1F5BEA37512A49AF983F"
"sha1": "C67FDDD66493F3F197D44B057D56A7B8554F11F2"
},
{
"name": "/FeedInventory.moon",
Expand All @@ -192,12 +195,12 @@
{
"name": "/FileLock.moon",
"url": "@{fileBaseUrl}",
"sha1": "28F79DB790CF4746441B6127A8953708D1A52501"
"sha1": "60A9B7FF96E0C7068EC7EE8BB72C27C2B34220EF"
},
{
"name": "/file-ops.moon",
"url": "@{fileBaseUrl}",
"sha1": "2FB92DBDC1AA861B7D5A859B165CE71C39E8FBD0"
"sha1": "A1835190664A941EFA21A7CF870460B4780F4BAC"
},
{
"name": "/FileOps.moon",
Expand All @@ -218,7 +221,7 @@
{
"name": "/JsonSchema.moon",
"url": "@{fileBaseUrl}",
"sha1": "2707158CB66573CEA10465C21E3AB42708E482F7"
"sha1": "0342242069D983F60D572626034199C89FBEE955"
},
{
"name": "/Lock.moon",
Expand All @@ -228,7 +231,7 @@
{
"name": "/Logger.moon",
"url": "@{fileBaseUrl}",
"sha1": "CBFC4B9C5699D3606D7AEE92137DA91DEC1B4EBD"
"sha1": "C9C32774B31904ED7B90864B4766802C754EAB3B"
},
{
"name": "/ModuleLoader.moon",
Expand Down Expand Up @@ -298,7 +301,7 @@
{
"name": "/ZipArchiver.moon",
"url": "@{fileBaseUrl}",
"sha1": "CC7A19E78B8B87DB70177A4A47CB10497232DF26"
"sha1": "EAD3E888DAA28DA5F4E00A251BB4740CE9CA87B8"
},
{
"name": "/helpers/ffi-posix.moon",
Expand Down Expand Up @@ -375,10 +378,20 @@
"url": "@{fileBaseUrl}",
"sha1": "651376FD6D8B4E30AB1BEEC41D5AD2E4A76C1493"
},
{
"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"
},
{
Expand Down Expand Up @@ -420,7 +433,7 @@
{
"name": "/FileCache.moon",
"url": "@{fileBaseUrl}",
"sha1": "633106FDCDD0AB81CFC2BB85725B9A7B0077AF83",
"sha1": "C2DB39F83ADB561A5C92CCCBC2D518064551DB76",
"type": "test"
},
{
Expand Down Expand Up @@ -456,7 +469,7 @@
{
"name": "/file-ops.moon",
"url": "@{fileBaseUrl}",
"sha1": "AB2BBDDFEDBB158963CFDFAE196C584E105598CC",
"sha1": "A503A1CAA3B0FAFA2D62BB4ABA651A3BF7C6F048",
"type": "test"
},
{
Expand All @@ -474,13 +487,13 @@
{
"name": "/JsonSchema.moon",
"url": "@{fileBaseUrl}",
"sha1": "125E4930956E7DCA578C2F88EDB4B56C14A031C6",
"sha1": "2B3998285534B6C9EAC9B36BAA8CEF5E0ABF7E08",
"type": "test"
},
{
"name": "/Lock.moon",
"url": "@{fileBaseUrl}",
"sha1": "26B3D5CA9E6D91C6F4D9411F093C26B3AE58FA96",
"sha1": "525628C362F564035CB0C866761FDC6CE7B52404",
"type": "test"
},
{
Expand Down Expand Up @@ -570,7 +583,7 @@
{
"name": "/ZipArchiver.moon",
"url": "@{fileBaseUrl}",
"sha1": "508996E6E08DFDA179327AFD70219344E21BD66E",
"sha1": "2D0AC676445C6E5E894B377981B4849DBC2EA9BF",
"type": "test"
},
{
Expand All @@ -594,7 +607,7 @@
{
"name": "/helpers/MockHttpServerController.moon",
"url": "@{fileBaseUrl}",
"sha1": "F4F84B3E954CB41DF6157ECF623B36DA3FF8EEBD",
"sha1": "AF0D432D6DD397460B7BC6A22E7C3BD978EC89EC",
"type": "test"
},
{
Expand Down Expand Up @@ -636,7 +649,7 @@
{
"name": "/domain.moon",
"url": "@{fileBaseUrl}",
"sha1": "2406064A5B8F234ECB69E9C60F5FC1A5E9B7657A",
"sha1": "6CEFB94846D96267E745F0F8A06D37147E234E2D",
"type": "test"
},
{
Expand All @@ -650,6 +663,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": [
Expand Down Expand Up @@ -806,6 +831,10 @@
],
"0.9.1": [
"fix: A required module that isn't managed by DependencyControl and declares a version that can't be parsed no longer fails to load with a 'Bad DependencyControl record' error. It is now treated as outdated and updated from its feed."
],
"0.10.0": [
"change: Log files and on-disk caches now follow the XDG Base Directory specification, moving to `?state/log` and `?cache/cache`; an Aegisub build that lacks the newer `?state` path token keeps writing logs to `?user/log`, and one that lacks `?cache` keeps its caches under `?local`, so path settings naming either token work 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)."
]
}
},
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `?cache`, `?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 `<namespace>/<name>` subdirectory (e.g. the feed cache at `<cache>/l0.DependencyControl/feeds`).
- _str_ **log ["?state/log"]:** Directory for DependencyControl's log files.
- _str_ **cache ["?cache/cache"]:** Base directory for on-disk caches. Each cache lives under a `<namespace>/<name>` subdirectory (e.g. the feed cache at `<cache>/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` and `?cache` are newer than `?user` and `?data`, so DependencyControl resolves them to `?user` and the legacy `?local` on an Aegisub build that doesn't know them — a path setting naming `?state` or `?cache` works on every version.

#### 2. Per-script Configuration

Expand Down
10 changes: 10 additions & 0 deletions STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 11 additions & 7 deletions depctrl.lua
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,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).
Expand Down Expand Up @@ -332,6 +332,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)
Expand Down Expand Up @@ -385,7 +386,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"))
Expand Down Expand Up @@ -413,10 +414,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)
Expand Down Expand Up @@ -564,6 +566,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"
Expand All @@ -574,7 +577,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")
Expand All @@ -586,7 +589,7 @@ elseif args.command == "serve-updates" then
local feedToServe = refreshed.changed > 0 and refreshedPath or feedPath
local serveChannels = args.serve_channel or {}
if #serveChannels > 0 then
local mergedPath = FileOps.joinPath(dirname(refreshedPath), "merged.json")
local mergedPath = PathOps.joinPath(dirname(refreshedPath), "merged.json")
local merged, mergeErr = UpdateFeed(nil, false, feedToServe):mergeChannels(UpdateFeed(nil, false, feedToServe), {
from = args.from_channel,
to = serveChannels,
Expand Down Expand Up @@ -861,6 +864,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)
Expand All @@ -882,7 +886,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
Expand Down
8 changes: 4 additions & 4 deletions macros/l0.DependencyControl.Toolbox.moon
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export script_name = "DependencyControl Toolbox"
export script_description = "Provides DependencyControl maintenance and configuration tools."
export script_version = "0.9.0" -- @{l0.DependencyControl.Toolbox:version}
export script_version = "0.10.0" -- @{l0.DependencyControl.Toolbox:version}
export script_author = "line0"
export script_namespace = "l0.DependencyControl.Toolbox"

Expand All @@ -17,7 +17,7 @@ SourceFeedKind = UpdateTask.SourceFeedKind
depRec = DepCtrl {
feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/DependencyControl.json",
{
{"l0.DependencyControl", version: "0.9.0"}
{"l0.DependencyControl", version: "0.10.0"}
}
}
logger = DepCtrl.logger
Expand Down Expand Up @@ -707,9 +707,9 @@ 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."}
label: "Cache directory", hint: "Base directory for on-disk caches such as the feed cache. ?cache falls back to ?local on an Aegisub that doesn't have it."}
}

configFieldsBySection = {}
Expand Down
8 changes: 6 additions & 2 deletions modules/l0/AegisubShims/aegisub.moon
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
-- Installs `aegisub` as a global before any module that requires Aegisub-specific APIs.
--
-- Configurable via environment variables:
-- DEPCTRL_USER_DIR — base for ?user / ?local (default: %APPDATA%\Aegisub / ~/.aegisub)
-- DEPCTRL_USER_DIR — base for ?user / ?local / ?cache (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"
Expand All @@ -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 = {}
Expand All @@ -27,10 +29,12 @@ makePackagePaths = (dir, ext) -> {"#{dir}/?.#{ext}", "#{dir}/?/init.#{ext}"}
-- ?audio, ?script, ?video are empty because no file is loaded headlessly.
pathTokens = {
"?audio": ""
"?cache": userDir
"?data": dataDir
"?dictionary": dataDir .. pathSep .. "dictionaries"
"?local": userDir
"?local": userDir -- the legacy alias ?cache replaced; both resolve alike, as in real Aegisub
"?script": ""
"?state": stateDir
"?temp": tempDir
"?user": userDir
"?video": ""
Expand Down
Loading
Loading