diff --git a/.gitignore b/.gitignore index 067f0b2..adbdca5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ !.gitignore !.rustfmt.toml !.taplo.toml +!flake.lock !Cargo.lock !Cargo.toml !README.md @@ -27,7 +28,10 @@ !public/** # Nix -!flake.lock +!.tack/ +!.tack/default.nix +!.tack/pins.toml +!.tack/pins.lock.json !flake.nix !nix/ !nix/package.nix diff --git a/.tack/default.nix b/.tack/default.nix new file mode 100644 index 0000000..71a5407 --- /dev/null +++ b/.tack/default.nix @@ -0,0 +1,331 @@ +# SPDX-License-Identifier: EUPL-1.2 +# tack-managed resolver. delete this line to take ownership; tack will leave it alone afterwards. + +let + inherit (builtins) + attrNames + attrValues + concatMap + elem + elemAt + filter + foldl' + fromJSON + head + intersectAttrs + isList + isString + listToAttrs + mapAttrs + match + pathExists + readFile + substring + tail + trace + ; + + call = + { + overrides ? { }, + }: + let + pins = fromTOML (readFile ./pins.toml); + lock = fromJSON (readFile ./pins.lock.json); + all_follow_raw = pins.all_follow or { }; + + # flatten `target = [aliases]` rows alongside `alias = "target"` rows + all_follow = foldl' ( + acc: key: + let + val = all_follow_raw.${key}; + in + if isList val then + acc + // { + ${key} = key; + } + // listToAttrs ( + map (a: { + name = a; + value = key; + }) val + ) + else if isString val then + acc // { ${key} = val; } + else + acc + ) { } (attrNames all_follow_raw); + + knownTypes = [ + "github" + "gitlab" + "git" + "tarball" + "path" + "indirect" + ]; + + # path nodes are convenience pins, so return the live local path directly + # because fetchTree rejects unlocked paths in pure eval + fetchPin = + name: + if !(lock ? ${name}) then + throw "tack: pin '${name}' has no lock entry; run tack update" + else + let + node = lock.${name}; + in + if (node.type or "") == "path" then + { + outPath = if substring 0 1 node.path == "/" then node.path else ./. + ("/" + node.path); + lastModified = node.lastModified or 0; + } + // (if node ? narHash then { inherit (node) narHash; } else { }) + else if !(elem (node.type or "") knownTypes) then + throw "tack: unknown lock type '${node.type or "?"}' for pin '${name}'" + else + fetchTree node; + + fetchFixed = + name: entry: + let + raw = derivation { + inherit name; + inherit (entry) url; + builder = "builtin:fetchurl"; + system = "builtin"; + outputHash = entry.sha256; + outputHashAlgo = "sha256"; + outputHashMode = "flat"; + }; + unpacked = derivation { + inherit name; + builder = "builtin:unpack-channel"; + system = "builtin"; + src = raw; + channelName = name; + }; + in + if (entry.unpack or "file") == "tarball" then unpacked.outPath + "/" + name else raw.outPath; + + resolveSpec = upLock: spec: if isList spec then walkPath upLock upLock.root spec else spec; + + walkPath = + upLock: nodeName: path: + if path == [ ] then + nodeName + else if !(upLock.nodes ? ${nodeName}) then + throw "tack: follows path dead-end: no node '${nodeName}' in flake.lock" + else + let + key = head path; + inputs = upLock.nodes.${nodeName}.inputs or { }; + in + if !(inputs ? ${key}) then + throw "tack: follows path dead-end: node '${nodeName}' has no input '${key}'" + else + walkPath upLock (resolveSpec upLock inputs.${key}) (tail path); + + followsFor = + pin: + let + rules = removeAttrs all_follow (pin.exclude_follow or [ ]); + in + { + level = rules // (pin.follows or { }); + deep = rules; + }; + + resolveFollows = mapAttrs ( + _: target: self.${target} or (throw "tack: follows target '${target}' is not a pin") + ); + + # follows key is `flake:name`, `tack:name`, or bare `name` + # project onto one side, rekeyed to bare names + followsForSide = + side: follows: + listToAttrs ( + concatMap ( + key: + let + m = match "(flake|tack):(.*)" key; + in + if m == null then + [ + { + name = key; + value = follows.${key}; + } + ] + else if head m == side then + [ + { + name = elemAt m 1; + value = follows.${key}; + } + ] + else + [ ] + ) (attrNames follows) + ); + + mkCallerInputs = + upLock: nodeName: rawInputs: levelFollows: deepFollows: + let + resolved = resolveFollows levelFollows; + in + mapAttrs ( + n: _decl: + resolved.${n} or ( + if upLock != null then + let + ref = + (upLock.nodes.${nodeName}.inputs or { }).${n} + or (throw "tack: input '${n}' declared but not in flake.lock node '${nodeName}'"); + childName = resolveSpec upLock ref; + childNode = upLock.nodes.${childName}; + childSrc = fetchTree childNode.locked; + in + if childNode.flake or true then evalTransitive upLock childName childSrc deepFollows else childSrc + else + throw "tack: no flake.lock; cannot resolve input '${n}'" + ) + ) rawInputs; + + mkFlakeResult = + sourceInfo: flakeDir: callerInputs: outputs: + outputs + // sourceInfo + // { + outPath = flakeDir; + inputs = callerInputs; + inherit outputs sourceInfo; + _type = "flake"; + }; + + evalFlake = + sourceInfo: flakeDir: upLock: nodeName: levelFollows: deepFollows: + let + raw = import (flakeDir + "/flake.nix"); + + tackPinsPath = flakeDir + "/.tack/pins.toml"; + hasTack = pathExists tackPinsPath; + upPins = if hasTack then fromTOML (readFile tackPinsPath) else { }; + + # project follows onto each side, keep only names that side has + # bare follow reaches both; `flake:`/`tack:` reaches just one + tackOverrides = resolveFollows ( + intersectAttrs (upPins.inputs or { }) (followsForSide "tack" levelFollows) + ); + flakeLevel = intersectAttrs (raw.inputs or { }) (followsForSide "flake" levelFollows); + + # deep follows pass down raw, so each descendant re-projects per side + callerInputs = mkCallerInputs upLock nodeName (raw.inputs or { }) flakeLevel deepFollows; + + # upstream declares its outputs forward tackOverrides; a closed `{ self }:` + # would throw on the extra kwarg, so forward only when declared + supportsOverrides = (upPins.tack or { }).recomposable or false; + + extraArgs = if supportsOverrides && tackOverrides != { } then { inherit tackOverrides; } else { }; + + outputs = raw.outputs (callerInputs // extraArgs // { self = result; }); + + result = + let + base = mkFlakeResult sourceInfo flakeDir callerInputs outputs; + in + if hasTack && tackOverrides != { } && !supportsOverrides then + trace "tack: ${flakeDir}: not marked recomposable (set [tack] recomposable = true); overrides will not reach upstream" base + else + base; + in + result; + + evalTransitive = + upLock: nodeName: sourceInfo: follows: + evalFlake sourceInfo sourceInfo.outPath upLock nodeName follows follows; + + evalTopFlake = + sourceInfo: pin: + let + flakeDir = sourceInfo.outPath + (if pin ? dir then "/" + pin.dir else ""); + upLockPath = flakeDir + "/flake.lock"; + upLock = if pathExists upLockPath then fromJSON (readFile upLockPath) else null; + rootNode = if upLock != null then upLock.root else null; + f = followsFor pin; + in + evalFlake sourceInfo flakeDir upLock rootNode f.level f.deep; + + evalFetch = + sourceInfo: pin: subdir: + let + path = sourceInfo.outPath + subdir; + tackPinsPath = path + "/.tack/pins.toml"; + hasTack = pathExists tackPinsPath; + upPins = if hasTack then fromTOML (readFile tackPinsPath) else { }; + f = followsFor pin; + # a fetch drill-in is tack-only + tackOverrides = resolveFollows ( + intersectAttrs (upPins.inputs or { }) (followsForSide "tack" f.level) + ); + in + # a fetch pin is a source tree (path); hand back resolved inputs only when + # there are overrides to push into the upstream's .tack + if hasTack && tackOverrides != { } then + let + upstream = import (path + "/.tack"); + in + # old resolvers return a plain attrset, not a callable functor + if upstream ? __functor then + (upstream { overrides = tackOverrides; }) // { outPath = path; } + else + trace "tack: ${path}: upstream .tack predates override support; overrides will not reach it" path + else + path; + + loadPin = + name: pin: + let + pinType = pin.type or (if pin.flake or true then "flake" else "fetch"); + subdir = if pin ? dir then "/" + pin.dir else ""; + in + if pinType == "fixed" then + fetchFixed name lock.${name} + else + let + sourceInfo = fetchPin name; + in + if pinType == "flake" then evalTopFlake sourceInfo pin else evalFetch sourceInfo pin subdir; + + declared = pins.inputs or { }; + + # undeclared lock entries are auto-dedup synthetics only when referenced as + # [all_follow] targets; stale locks from hand-edits are ignored (tack rm to clean) + autoTargets = listToAttrs ( + map (target: { + name = target; + value = true; + }) (attrValues all_follow) + ); + autoNames = filter (n: !(declared ? ${n}) && autoTargets ? ${n}) (attrNames lock); + autoPin = + name: + let + sourceInfo = fetchPin name; + in + if pathExists (sourceInfo.outPath + "/flake.nix") then evalTopFlake sourceInfo { } else sourceInfo; + + self = + (mapAttrs loadPin declared) + // listToAttrs ( + map (name: { + inherit name; + value = autoPin name; + }) autoNames + ) + // overrides; + in + self // { __functor = _: call; }; +in +call { } diff --git a/.tack/pins.lock.json b/.tack/pins.lock.json new file mode 100644 index 0000000..c73612c --- /dev/null +++ b/.tack/pins.lock.json @@ -0,0 +1,18 @@ +{ + "fenix": { + "type": "github", + "owner": "nix-community", + "repo": "fenix", + "rev": "fa2a0be0f712d7147d1677d33d15ea7b0589cdb4", + "narHash": "sha256-49WO85egjNtN1vMgJ3zUjDh5IqO+ou4zZY80WQ6EKGg=", + "lastModified": 1784017020 + }, + "nixpkgs": { + "type": "github", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6cdc7fc76e8bf7fde9fa43a849fcaaa70e230dee", + "narHash": "sha256-FmieJB8/OUvNxbkboi7+IGfIuSXY3nF/hZQm8kD0r50=", + "lastModified": 1783915482 + } +} diff --git a/.tack/pins.toml b/.tack/pins.toml new file mode 100644 index 0000000..320aef7 --- /dev/null +++ b/.tack/pins.toml @@ -0,0 +1,11 @@ +[all_follow] +nixpkgs = "nixpkgs" + +[tack] +recomposable = true + +[inputs.fenix] +url = "github:nix-community/fenix" + +[inputs.nixpkgs] +url = "github:NixOS/nixpkgs/nixpkgs-unstable" diff --git a/flake.lock b/flake.lock index 91a4d55..5999137 100644 --- a/flake.lock +++ b/flake.lock @@ -1,65 +1,6 @@ { "nodes": { - "fenix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src" - }, - "locked": { - "lastModified": 1773646590, - "narHash": "sha256-qwnecNC3DB0hSu6MvU27xh/Mg9uPbmmg7d1wBOtO7ds=", - "owner": "nix-community", - "repo": "fenix", - "rev": "350a4df2afc34c1ae115173e0509cec7067a06c9", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1773597492, - "narHash": "sha256-hQ284SkIeNaeyud+LS0WVLX+WL2rxcVZLFEaK0e03zg=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "a07d4ce6bee67d7c838a8a5796e75dff9caa21ef", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "fenix": "fenix", - "nixpkgs": "nixpkgs" - } - }, - "rust-analyzer-src": { - "flake": false, - "locked": { - "lastModified": 1773543526, - "narHash": "sha256-CKmkYqUi2pI1uDGDfpK0mkZbRLyjUKCpYDU3eMHtmks=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "90c8906e6443e7cee18cece9c2621a8b1c10794c", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - } + "root": {} }, "root": "root", "version": 7 diff --git a/flake.nix b/flake.nix index 0630845..5db08e2 100644 --- a/flake.nix +++ b/flake.nix @@ -1,17 +1,11 @@ { description = "A privacy-focused Twitter/X frontend written in Rust"; - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - fenix = { - url = "github:nix-community/fenix"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - outputs = - { nixpkgs, fenix, ... }: + { self, ... }@args: let + inputs = (import ./.tack) { overrides = args.tackOverrides or { }; }; + inherit (inputs) nixpkgs fenix; inherit (nixpkgs) lib; forAllSystems = lib.genAttrs (lib.systems.doubles.linux ++ lib.systems.doubles.darwin); pkgsFor = system: nixpkgs.legacyPackages.${system} or (import nixpkgs { inherit system; });