commit 69aedbe3081384128d7b06c4da18eb6cfcdf442d Author: Bryan Bennett Date: Thu Dec 21 08:51:20 2023 -0500 Initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0647cd6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Bryan Bennett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d3ac9d --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# flake_env + +Yet another direnv plugin for flakes. +This one is kind of experimental. + +## Why not `nix-direnv`? + +I am one of the core contributors on nix_direnv, but wanted to try a new approach. + +nix-direnv got held up a bit by depending on differing behaviors of external programs not shipped with bash +(or things that *are* shipped with bash, but a differing implementation ended up in front of the bash version in PATH). + +This is an attempt to simplify a bit. +I ported the nix-direnv `use_flake` function to ReasonML. +Implementing most things by hand in ReasonML is pretty simple and (more importantly) portable. +This removes *most* of the dependencies besides bash and nix. +The bash that exists is pretty portable between versions, since it only does environment manipulation. + +## Installation & Usage + +### Installation +These details are evolving. +I'll make sure to update this document when things change. + +For now, there is no NixOS, nix-darwin, or home-manager module that points at this tool. +The only thing you can really do is use this repo's flake as an input. +Then you'll probably need to source `${flake_env}/share/flake_env/direnvrc`. +You should be able to do that with: + +* `programs.direnv.direnvrcExtra` on NixOS +* `programs.direnv.stdlib` if using home-manager + + +### Usage + +Example `.envrc`: + +```sh +watch_file **/*.nix +use flake_env . +``` + +## Credits + +This takes huge inspiration (and literal code-chunks) from nix-direnv. +Thanks to Mic92 for nix-direnv and kingarrrt for their recent contributions. + diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..11c71ad --- /dev/null +++ b/default.nix @@ -0,0 +1,44 @@ +{ buildDunePackage +, lib +, core +, core_unix +, findlib +, ocaml +, ppx_yojson_conv +, ppx_yojson_conv_lib +, re +, reason +, sha +}: +buildDunePackage { + pname = "flake_env"; + version = "0.1"; + src = ./.; + duneVersion = "3"; + postPatch = '' + substituteInPlace --replace "flake_env" "$out/bin/flake_env" direnvrc + ''; + postInstall = '' + install -m400 -D direnvrc $out/share/flake_env/direnvrc + ''; + nativeBuildInputs = [ + reason + ]; + propagatedBuildInputs = [ + core + core_unix + findlib + ocaml + ppx_yojson_conv + ppx_yojson_conv_lib + re + sha + ]; + + meta = with lib; { + description = "Yet another flake plugin for direnv"; + homepage = "https://git.sr.ht/~bryan_bennett/flake_env"; + license = licenses.mit; + platforms = platforms.unix; + }; +} diff --git a/direnvrc b/direnvrc new file mode 100644 index 0000000..a6ffd2c --- /dev/null +++ b/direnvrc @@ -0,0 +1,68 @@ +# The below code is taken largely from nix-direnv. +# nix-direnv's license is replicated below: +# +# Copyright (c) 2019 Nix community projects +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +_nix_export_or_unset() { + local key=$1 value=$2 + if [[ $value == __UNSET__ ]]; then + unset "$key" + else + export "$key=$value" + fi +} + +use_flake_env() { + local old_nix_build_top=${NIX_BUILD_TOP:-__UNSET__} + local old_tmp=${TMP:-__UNSET__} + local old_tmpdir=${TMPDIR:-__UNSET__} + local old_temp=${TEMP:-__UNSET__} + local old_tempdir=${TEMPDIR:-__UNSET__} + local old_xdg_data_dirs=${XDG_DATA_DIRS:-} + + local ld=$(direnv_layout_dir) + eval $(flake_env "$1" "$ld") + + # `nix print-dev-env` will create a temporary directory and use it as TMPDIR + # We cannot rely on this directory being available at all times, + # as it may be garbage collected. + # Instead - just remove it immediately. + # Use recursive & force as it may not be empty. + if [[ -n ${NIX_BUILD_TOP+x} && $NIX_BUILD_TOP == */nix-shell.* && -d $NIX_BUILD_TOP ]]; then + rm -rf "$NIX_BUILD_TOP" + fi + + _nix_export_or_unset NIX_BUILD_TOP "$old_nix_build_top" + _nix_export_or_unset TMP "$old_tmp" + _nix_export_or_unset TMPDIR "$old_tmpdir" + _nix_export_or_unset TEMP "$old_temp" + _nix_export_or_unset TEMPDIR "$old_tempdir" + local new_xdg_data_dirs=${XDG_DATA_DIRS:-} + export XDG_DATA_DIRS= + local IFS=: + for dir in $new_xdg_data_dirs${old_xdg_data_dirs:+:}$old_xdg_data_dirs; do + dir="${dir%/}" # remove trailing slashes + if [[ :$XDG_DATA_DIRS: == *:$dir:* ]]; then + continue # already present, skip + fi + XDG_DATA_DIRS="$XDG_DATA_DIRS${XDG_DATA_DIRS:+:}$dir" + done +} diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..063ff61 --- /dev/null +++ b/dune-project @@ -0,0 +1,24 @@ +(lang dune 3.11) + +(name flake_env) + +(generate_opam_files true) + +(source + (sourcehut bryan_bennett/flake_env)) + +(authors "Bryan Bennett") + +(maintainers "Bryan Bennett") + +(license MIT) + +(documentation https://git.sr.ht/~bryan_bennett/flake_env) + +(package + (name flake_env) + (allow_empty) + (synopsis "Yet another flake plugin for direnv") + (depends ocaml dune) + (tags + ("direnv" "nix" "flake"))) \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d11956c --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1701473968, + "narHash": "sha256-YcVE5emp1qQ8ieHUnxt1wCZCC3ZfAS+SRRWZ2TMda7E=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "34fed993f1674c8d06d58b37ce1e0fe5eebcb9f5", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1702272962, + "narHash": "sha256-D+zHwkwPc6oYQ4G3A1HuadopqRwUY/JkMwHz1YF7j4Q=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e97b3e4186bcadf0ef1b6be22b8558eab1cdeb5d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..0b3ebd3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,44 @@ +{ + description = "Yet another flake plugin for direnv"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-parts = { + url = "github:hercules-ci/flake-parts"; + inputs.nixpkgs-lib.follows = "nixpkgs"; + }; + }; + + outputs = inputs @ { flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } + ({ lib, ... }: { + systems = [ + "aarch64-linux" + "x86_64-linux" + + "x86_64-darwin" + "aarch64-darwin" + ]; + perSystem = { config, pkgs, self', ... }: { + packages = { + flake_env = pkgs.ocamlPackages.callPackage ./default.nix { }; + default = config.packages.flake_env; + }; + devShells.default = pkgs.mkShell { + inputsFrom = [ self'.packages.default ]; + packages = [ + pkgs.ocamlPackages.dune_3 + pkgs.ocamlPackages.findlib + pkgs.ocamlPackages.ocaml + pkgs.ocamlPackages.ocaml-lsp + pkgs.ocamlPackages.ocamlformat + pkgs.ocamlPackages.ocamlformat-rpc-lib + ]; + }; + }; + flake = { + overlays.default = final: _prev: { + flake_env = final.callPackage ./default.nix { }; + }; + }; + }); +} diff --git a/flake_env.opam b/flake_env.opam new file mode 100644 index 0000000..106f38f --- /dev/null +++ b/flake_env.opam @@ -0,0 +1,30 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "Yet another flake plugin for direnv" +maintainer: ["Bryan Bennett"] +authors: ["Bryan Bennett"] +license: "MIT" +tags: ["direnv" "nix" "flake"] +homepage: "https://sr.ht/~bryan_bennett/flake_env" +doc: "https://git.sr.ht/~bryan_bennett/flake_env" +bug-reports: "https://todo.sr.ht/~bryan_bennett/flake_env" +depends: [ + "ocaml" + "dune" {>= "3.11"} + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +dev-repo: "git+https://git.sr.ht/~bryan_bennett/flake_env" diff --git a/src/dune b/src/dune new file mode 100644 index 0000000..a3bae25 --- /dev/null +++ b/src/dune @@ -0,0 +1,5 @@ +(executable + (name flake_env) + (public_name flake_env) + (libraries core core_unix core_unix.filename_unix core_unix.sys_unix ppx_yojson_conv re sha) + (preprocess (pps ppx_yojson_conv ppx_jane))) diff --git a/src/flake_env.re b/src/flake_env.re new file mode 100644 index 0000000..ca0eefa --- /dev/null +++ b/src/flake_env.re @@ -0,0 +1,245 @@ +open Core; +module Unix = Core_unix; + +type version_number = { + major: int, + minor: int, + point: int, +}; + +[@deriving yojson] +type watch = { + exists: bool, + modtime: int, + path: string +}; + +[@deriving yojson] +type watches = array; + +let required_direnv_version = { + major: 2, + minor: 21, + point: 3 +}; + +let required_nix_version = { + major: 2, + minor: 10, + point: 0 +}; + +// TODO: test this +let compare_version_number = (a, b) => { + switch (a, b) { + | (a, b) when a.major == b.major && a.minor == b.minor && a.point == b.point => 0 + | (a, b) when a.major < b.major => -1 + | (a, b) when a.major == b.major && a.minor < b.minor => -1 + | (a, b) when a.major == b.major && a.minor == b.minor && a.point < b.point => -1 + | _ => 1 + } +} + +let semver_re = Re.compile(Re.Posix.re({|([0-9]+)\.([0-9]+)\.([0-9]+)|})); + +let extract_version_number = (cmd) => { + let full_cmd = cmd ++ " --version"; + switch (Core_unix.open_process_in(full_cmd) |> In_channel.input_line) { + | Some(stdout) => { + let substrings = Re.exec(semver_re, stdout); + let groups = Re.Group.all(substrings); + Ok({ + major: groups[1] |> int_of_string, + minor: groups[2] |> int_of_string, + point: groups[3] |> int_of_string + }) + } + | None => Error(Printf.sprintf("Failed executing '%s'", cmd)) + } +}; + +let is_version_new_enough = (cur, needed) => { + switch (cur) { + | Ok(cur) => { + switch (compare_version_number(cur, needed)) { + | x when x < 0 => Ok(false) + | _ => Ok(true) + } + } + | Error(e) => Error(e) + } +} + +let preflight_versions = () => { + let in_direnv = switch (Sys.getenv("direnv")) { + | Some(_) => true + | None => false + }; + + let is_nix_new_enough = is_version_new_enough(extract_version_number("nix"), required_nix_version); + let is_direnv_new_enough = is_version_new_enough(extract_version_number("direnv"), required_direnv_version); + + switch (in_direnv, is_direnv_new_enough, is_nix_new_enough) { + | (false, _, _) => Error("Not in direnv!") + | (_, Ok(false), _) => Error("Direnv version is not new enough") + | (_, _, Ok(false)) => Error("Nix version is not new enough") + | (_, Error(e), _) => Error(e) + | (_, _, Error(e)) => Error(e) + | (true, Ok(true), Ok(true)) => Ok() + } +}; + +let preflight = (layout_directory) => { + switch (preflight_versions()) { + | Ok(_) => { + switch (Sys_unix.is_directory(layout_directory)) { + | `Yes => Ok() + | _ => { + Unix.mkdir_p(layout_directory); + Ok() + } + } + } + | err => err + } + +}; + +let hash_files = (filenames) => { + let ctx = Sha1.init(); + let () = filenames + |> Array.filter(~f=f => switch (Sys_unix.file_exists(f)) { + | `Yes => true + | _ => false + }) + |> Array.iter(~f=(f) => { + f |> In_channel.create |> In_channel.input_all |> Sha1.update_string(ctx); + }); + Sha1.finalize(ctx) |> Sha1.to_hex +}; + +let get_watches = () => { + let direnv_watch_str = Sys.getenv("DIRENV_WATCHES") |> Option.value_exn(~message="Environment missing DIRENV_WATCHES"); + let proc_info = Unix.create_process(~prog="direnv", ~args=["show_dump", direnv_watch_str]); + let sub_stdout = Unix.in_channel_of_descr(proc_info.stdout); + switch (Unix.waitpid(proc_info.pid)) { + | Ok() => Ok(watches_of_yojson(Yojson.Safe.from_channel(sub_stdout))) + | _ => Error("Failed to parse watches") + } +}; + +let rec rmrf = (path) => switch (Sys_unix.is_directory(~follow_symlinks=false, path)) { + | `Yes => { + Sys_unix.readdir(path) |> Array.iter(~f=name => { print_endline(Filename.concat(path, name)); rmrf(Filename.concat(path, name))}); + Unix.rmdir(path) + } + | `No => { + switch (Sys_unix.is_file(~follow_symlinks=false, path)) { + | `Yes => Sys_unix.remove(path) + | _ => () + } + } + | `Unknown => { + Printf.eprintf("Cannot determine what file type of path %s", path); + exit(1); + } +}; + +let clean_old_gcroots = (layout_dir) => { + rmrf(layout_dir ++ "/flake-inputs/"); + rmrf(layout_dir); + Unix.mkdir_p(layout_dir ++ "/flake-inputs/"); +}; + +let print_cur_cache = (profile_rc) => { + In_channel.read_all(profile_rc) |> Printf.printf("%s") +}; + +let nix = (args) => { + let stdout_chan = Unix.open_process_in( + "nix --extra-experimental-features \"nix-command flakes\" " ++ (args |> String.concat(~sep=" "))) + + let stdout_content = stdout_chan |> In_channel.input_all; + let exit_code = Unix.close_process_in(stdout_chan); + (exit_code, stdout_content) +} + +let freshen_cache = (layout_dir, hash, flake_specifier) => { + clean_old_gcroots(layout_dir); + let tmp_profile = layout_dir ++ "flake-tmp-profile." ++ Core.Pid.to_string(Core_unix.getpid()); + let (exit_code, stdout_content) = nix(["print-dev-env", "--profile", tmp_profile, flake_specifier]); + + let profile = layout_dir ++ "/flake-profile-" ++ hash; + let profile_rc = profile ++ ".rc"; + + switch (exit_code) { + | Ok() => { + Out_channel.with_file(~f=f=> Out_channel.output_string(f, stdout_content), profile_rc); + switch (nix(["build", "--out-link", profile, tmp_profile])) { + | (Ok(), _) => { + Sys_unix.remove(tmp_profile); + print_cur_cache(profile_rc); + } + | (err, _) => { + Printf.eprintf("Failed creating gcroot: %s\n", Core_unix.Exit_or_signal.to_string_hum(err)); + exit(1); + } + }; + } + | err => { + Printf.eprintf("Failed evaluating flake: %s\n", Core_unix.Exit_or_signal.to_string_hum(err)); + exit(1); + } + }; + +}; + +// TODO: Extend to add support for additional flags, maybe? +let main = () => { + let argv = Sys.get_argv(); + switch (Array.length(argv)) { + | 3 => { + let flake_specifier = argv[1]; + let layout_directory = argv[2]; + switch (preflight(layout_directory)) { + | Ok() => { + switch (get_watches()) { + | Ok(watches) => { + let paths = Array.map(~f=watch => watch.path, watches); + let hash = hash_files(paths); + let profile = layout_directory ++ "/flake-profile-" ++ hash; + let profile_rc = profile ++ ".rc"; + + switch ((Sys_unix.is_file(profile_rc), Sys_unix.is_file(profile))) { + | (`Yes, `Yes) => { + let profile_rc_mtime = Unix.stat(profile_rc).st_mtime; + let all_older= Array.map(~f=watch => watch.modtime, watches) + |> Array.for_all(~f=watch_mtime => watch_mtime <= int_of_float(profile_rc_mtime)) + switch (all_older) { + | true => print_cur_cache(profile_rc) + | false => freshen_cache(layout_directory, hash, flake_specifier) + } + } + | _ => freshen_cache(layout_directory, hash, flake_specifier) + } + }; + | Error(e) => { + Printf.eprintf("%s", e); + exit(1); + } + } + } + | Error(e) => { + Printf.eprintf("%s", e); + exit(1); + } + }; + } + | _ => { + Printf.eprintf("%s \n", argv[0]); + exit(1); + } + } +} + +let () = main();