;;; nix-flake.el --- Transient interface to Nix flake commands -*- lexical-binding: t -*- ;; Keywords: nix, languages, tools, unix ;; Package-Requires: ((emacs "27.1") (transient "0.3")) ;; Homepage: https://github.com/NixOS/nix-mode ;;; Commentary: ;; This library provides transient interface to experimental commands in Nix. ;; See the Nix manual for more information available at ;; https://nixos.org/manual/nix/unstable/command-ref/experimental-commands.html ;;; Code: (require 'nix) (require 'transient) (defgroup nix-flake nil "Nix flake commands." :group 'nix) ;;;; Custom variables (defcustom nix-flake-init-post-action 'open-flake-nix "Action to run after successfully initializing a flake. This action is run after a flake is successfully initialized by `nix-flake-init` (or generally `nix-flake-dispatch`). You can also specify a function, which should take no arguments. It is called in the directory of the flake." :type '(choice (const :tag "Open flake.nix" open-flake-nix) (const :tag "Do nothing" nil) (function :tag "User-defined function"))) (defcustom nix-flake-add-to-registry t "Whether to add a new flake to registry. When this variable is non-nil, every flake reference from the interactive input is added to the flake registry, unless it is already registered in either the user or the global registry." :type 'boolean) ;;;; Transient classes ;;;;; flake-ref (defclass nix-flake-ref-variable (transient-variable) ((constant-value :initarg :constant-value :initform nil) (on-change :initarg :on-change) (reader :initarg :reader :initform nil))) (cl-defmethod transient-init-value ((_obj nix-flake-ref-variable)) "Set the initial value of the object.") (cl-defmethod transient-infix-read ((obj nix-flake-ref-variable)) "Determine the new value of the infix object OBJ." (if-let (value (oref obj constant-value)) (if (symbolp value) (symbol-value value) value) (if-let (reader (oref obj reader)) (funcall reader "Flake directory: " (oref obj value)) (nix-flake--read-flake-ref nil (oref obj value))))) (cl-defmethod transient-infix-set ((obj nix-flake-ref-variable) value) "Set the value of infix object OBJ to VALUE." (oset obj value value) (when-let (func (oref obj on-change)) (funcall func value))) (cl-defmethod transient-format-value ((_obj nix-flake-ref-variable)) "Format the object's value for display and return the result." ;; Don't show the value "") ;;;; Utility functions (defun nix-flake--to-list (x) "If X is not a list, make a singleton list containing it." (if (listp x) x (list x))) ;;;; Registry ;; Maybe we'll move these functions to a separate library named nix-registry.el. (defun nix-flake--registry-list () "Return a list of entries from the registry." (cl-flet ((split-entry (s) (split-string s "[[:space:]]+"))) (thread-last (nix--process-lines "registry" "list") (mapcar #'split-entry)))) (defun nix-flake--registry-refs () "Return a list of flake refs in the registry." (thread-last (nix-flake--registry-list) ;; I don't know if I should include flakes in the ;; system registry. It's ugly to display full ;; checksums, so I won't include them for now. (cl-remove-if-not (pcase-lambda (`(,type . ,_)) (member type '("user" "global")))) (mapcar (lambda (cells) ;; Both references and referees are included in the output. ;; It may be better to pick only one and show others as ;; decoration, e.g. using marginalia, but it is not supported ;; for now. (list (nth 1 cells) (nth 2 cells)))) (flatten-list))) (defun nix-flake--registry-add-1 (flake-ref) "Add FLAKE-REF to the registry with a new name." (let ((name (read-string (format-message "Enter the registry name for %s: " flake-ref)))) (unless (or (not name) (string-empty-p name)) (start-process "nix registry add" "*nix registry add*" nix-executable "registry" "add" name flake-ref)))) ;; This argument complies the standard reader interface of transient ;; just in case, but it may not be necessary. (defun nix-flake--read-flake-ref (&optional prompt initial-input history) "Select a flake from the registry. For PROMPT, INITIAL-INPUT, and HISTORY, see the documentation of readers in transient.el." (let* ((registered-flakes (nix-flake--registry-refs)) (input (string-trim (completing-read (or prompt "Flake URL: ") registered-flakes nil nil nil history initial-input)))) (prog1 input (when (and nix-flake-add-to-registry (not (member input registered-flakes))) (nix-flake--registry-add-1 input))))) ;;;; nix-flake command ;;;;; Variables (defvar nix-flake-ref nil) ;;;;; Setting the flake (transient-define-infix nix-flake:from-registry () :class 'nix-flake-ref-variable :on-change (lambda (flake-ref) (nix-flake--set-flake flake-ref :remote t) (transient-update)) :description "Select a flake from the registry") (transient-define-infix nix-flake:flake-directory () :class 'nix-flake-ref-variable :reader 'nix-flake--read-directory :on-change (lambda (flake-ref) (nix-flake--set-flake flake-ref) (transient-update)) :description "Select a directory") (defun nix-flake--read-directory (prompt &optional initial-input _history) "Select a directory containing a flake. For PROMPT and INITIAL-INPUT, see the documentation of transient.el." (let ((input (string-remove-suffix "/" (read-directory-name prompt initial-input nil t)))) (prog1 (expand-file-name input) (unless (file-exists-p (expand-file-name "flake.nix" input)) (user-error "The selected directory does not contain flake.nix")) (when (and nix-flake-add-to-registry (not (member (concat "path:" input) (nix-flake--registry-refs)))) (nix-flake--registry-add-1 input))))) ;;;;; --update-input (defclass nix-flake--update-input-class (transient-option) ()) (transient-define-infix nix-flake-arg:update-input () :class 'nix-flake--update-input-class :argument "--update-input" :reader 'nix-flake--read-input-path :prompt "Input: " :description "Update a specific flake path") (cl-defmethod transient-format-value ((obj nix-flake--update-input-class)) "Format --update-input arguments from OBJ." (let ((value (oref obj value))) (propertize (concat (oref obj argument) (when value (concat " " value))) 'face (if value 'transient-value 'transient-inactive-value)))) (cl-defmethod transient-infix-value ((obj nix-flake--update-input-class)) "Return the value of the suffix object OBJ." (when-let ((value (oref obj value))) (list (oref obj argument) value))) (defun nix-flake--input-names () "Return a list of inputs to the flake." (thread-last (nix--process-json "flake" "info" nix-flake-ref "--json") (alist-get 'locks) (alist-get 'nodes) (alist-get 'root) (alist-get 'inputs) (mapcar #'cdr))) (defun nix-flake--read-input-path (prompt initial-input _history) "Read an input name of a flake from the user. For PROMPT and INITIAL-INPUT, see the documentation of :reader in transient.el." (completing-read prompt (nix-flake--input-names) nil nil initial-input)) ;;;;; Attribute names (defvar nix-flake-outputs nil) (defun nix-flake-system-attribute-names (types) "Return a list of output attributes of particular TYPES." (let ((system (intern (nix-system)))) (thread-last nix-flake-outputs (mapcar (pcase-lambda (`(,type . ,alist)) (when (memq type types) (mapcar #'car (alist-get system alist))))) (apply #'append) (cl-remove-duplicates) (mapcar #'symbol-name)))) (defun nix-flake--run-attribute-names () "Return possible attribute names for run command." (nix-flake-system-attribute-names '(apps packages))) (defun nix-flake--build-attribute-names () "Return possible attribute names for build command." (nix-flake-system-attribute-names '(packages))) (defun nix-flake--default-run-p () "Return non-nil if there is the default derivation for run command." (not (null (nix-flake-system-attribute-names '(defaultApp defaultPackage))))) (defun nix-flake--default-build-p () "Return non-nil if there is the default derivation for build command." (not (null (nix-flake-system-attribute-names '(defaultPackage))))) ;;;;; Building command lines (defun nix-flake--options () "Return a list of options for `nix-flake-dispatch'." (flatten-list (transient-args 'nix-flake-dispatch))) (defun nix-flake--command (subcommand options flake-ref) "Build a command line for a Nix subcommand. SUBCOMMAND is a string or a list of strings which is a subcommand of Nix. OPTIONS is a list of options appended after FLAKE-REF. COMMAND-ARGUMENTS is extra arguments to the command after the flake reference." (concat nix-executable " " (mapconcat #'shell-quote-argument `(,@(nix-flake--to-list subcommand) ,flake-ref ,@options) " "))) (defun nix-flake--installable-command (subcommand options flake-ref attribute &optional extra-arguments) "Build a command line for a Nix subcommand. This is like `nix-flake--command', but for a subcommand which takes an installable as an argument. See the user manual of Nix for what installable means. SUBCOMMAND, OPTIONS, and FLAKE-REF are the same as in the function. ATTRIBUTE is the name of a package, app, or anything that refers to a derivation in the flake. It must be a string that is concatenated with the sharp symbol in the installable reference. EXTRA-ARGUMENTS is a list of strings passed to the Nix command after \"--\". Note that some commands such as \"nix build\" do not take the extra arguments." (concat nix-executable " " (mapconcat #'shell-quote-argument `(,@(nix-flake--to-list subcommand) ,(if attribute (concat flake-ref "#" attribute) flake-ref) ,@options) " ") (if extra-arguments (concat " -- " extra-arguments) ""))) ;;;;; Individual subcommands (defun nix-flake-run-attribute (options flake-ref attribute command-args) "Run an app in the current flake. OPTIONS and FLAKE-REF are the same as in other Nix commands. ATTRIBUTE is the name of a package or app in the flake, and COMMAND-ARGS is an optional list of strings passed to the application." (interactive (list (nix-flake--options) nix-flake-ref (completing-read "Nix app/package: " (nix-flake--run-attribute-names)) nil)) (compile (nix-flake--installable-command "run" options flake-ref attribute command-args))) (defun nix-flake-run-default (options flake-ref command-args) "Run the default app or package in the current flake. For OPTIONS, FLAKE-REF, and COMMAND-ARGS, see the documentation of `nix-flake-run-attribute'." (interactive (list (nix-flake--options) nix-flake-ref nil)) (compile (nix-flake--installable-command "run" options flake-ref nil command-args))) (defun nix-flake-build-attribute (options flake-ref attribute) "Build a derivation in the current flake. For OPTIONS, FLAKE-REF, and ATTRIBUTE, see the documentation of `nix-flake-run-attribute'." (interactive (list (nix-flake--options) nix-flake-ref (completing-read "Nix package: " (nix-flake--build-attribute-names)))) (compile (nix-flake--installable-command "build" options flake-ref attribute))) (defun nix-flake-build-default (options flake-ref) "Build the default package in the current flake. For OPTIONS and FLAKE-REF, see the documentation of `nix-flake-run-attribute'." (interactive (list (nix-flake--options) nix-flake-ref)) (compile (nix-flake--installable-command "build" options flake-ref nil))) (defun nix-flake-check (options flake-ref) "Check the flake. For OPTIONS and FLAKE-REF, see the documentation of `nix-flake-run-attribute'." (interactive (list (nix-flake--options) nix-flake-ref)) (compile (nix-flake--command '("flake" "check") options flake-ref))) (defun nix-flake-lock (options flake-ref) "Create missing lock file entries. For OPTIONS and FLAKE-REF, see the documentation of `nix-flake-run-attribute'." (interactive (list (nix-flake--options) nix-flake-ref)) (compile (nix-flake--command '("flake" "lock") options flake-ref))) (defun nix-flake-update (options flake-ref) "Update the lock file. For OPTIONS and FLAKE-REF, see the documentation of `nix-flake-run-attribute'." (interactive (list (nix-flake--options) nix-flake-ref)) (compile (nix-flake--command '("flake" "update") options flake-ref))) ;;;###autoload (autoload 'nix-flake-dispatch "nix-flake" nil t) (transient-define-prefix nix-flake-dispatch (flake-ref &optional remote) "Run a command on a Nix flake." :value '("--print-build-logs") [:description nix-flake--description ("=r" nix-flake:from-registry) ("=d" nix-flake:flake-directory)] ["Arguments" ("-i" "Allow access to mutable paths and repositories" "--impure") ("-ui" nix-flake-arg:update-input) ("-nu" "Do not allow any updates to the flake's lock file" "--no-update-lock-file") ("-cl" "Commit changes to the flake's lock file" "--commit-lock-file") ("-L" "Print build logs" "--print-build-logs")] ["Installable commands" ("r" "Run attribute" nix-flake-run-attribute) ("R" "Run default" nix-flake-run-default :if nix-flake--default-run-p) ("b" "Build attribute" nix-flake-build-attribute) ("B" "Build default" nix-flake-build-default :if nix-flake--default-build-p)] ["Flake commands" ("c" "flake check" nix-flake-check) ("l" "flake lock" nix-flake-lock) ("u" "flake update" nix-flake-update)] (interactive (list (convert-standard-filename default-directory))) (nix-flake--set-flake flake-ref :remote remote) (transient-setup 'nix-flake-dispatch)) (cl-defun nix-flake--set-flake (flake-ref &key remote) "Set the flake of the transient interface. FLAKE-REF and REMOTE should be passed down from `nix-flake-dispatch'." (setq nix-flake-ref flake-ref) (setq nix-flake-outputs (if remote (nix--process-json "flake" "show" "--json" nix-flake-ref) (let ((default-directory flake-ref)) (nix--process-json "flake" "show" "--json"))))) (defun nix-flake--description () "Describe the current flake." (concat "Flake: " nix-flake-ref)) ;; A wrapper function for ensuring existence of flake.nix and flake.lock ;; in the project directory. ;;;###autoload (cl-defun nix-flake (dir &key flake-ref) "Dispatch a transient interface for Nix commands. DIR is a directory on the file system in which flake.nix resides. Alternatively, you can specify FLAKE-REF which follows the syntax of flake-url. It can refer to a remote url, a local file path, or whatever supported by Nix." (interactive (pcase current-prefix-arg ('(4) (list nil :flake-ref (nix-flake--read-flake-ref))) ('(16) (if nix-flake-ref (list nil :flake-ref nix-flake-ref) (user-error "Last flake is unavailable"))) (_ (list default-directory)))) (cl-assert (or (and (stringp dir) (file-directory-p dir)) flake-ref) nil "DIR or FLAKE-REF must be specified") (cond (flake-ref (nix-flake-dispatch flake-ref t)) ((file-exists-p (expand-file-name "flake.lock" dir)) (nix-flake-dispatch (nix-flake--directory-ref dir))) ((file-exists-p (expand-file-name "flake.nix" dir)) (message "You have not created flake.lock yet, so creating it...") (let ((default-directory dir)) (nix-flake--command '("flake" "lock") nil (nix-flake--directory-ref dir)))) (t (nix-flake-init-dispatch)))) (defun nix-flake--directory-ref (dir) "Return the flake ref for a local DIR." (expand-file-name dir)) ;;;; nix flake init ;;;;; Setting the template repository (defvar nix-flake-template-repository nil "Flake reference to the current template sets.") (defvar nix-flake-template-name nil "Attribute name of the last used template.") (defun nix-flake--init-source () "Describe the current template repository for init command." (format "Template repository: %s" nix-flake-template-repository)) (transient-define-infix nix-flake-init:from-registry () :class 'nix-flake-ref-variable :variable 'nix-flake-template-repository :description "Select from the registry") (transient-define-infix nix-flake-init:default-templates () :class 'nix-flake-ref-variable :variable 'nix-flake-template-repository :constant-value "flake:templates" :description "Use the default template set") ;;;;; Running the command (defun nix-flake--init (flake-ref template-name) "Initialize a flake from a template. FLAKE-REF must be a reference to a flake which contains the template, TEMPLATE-NAME is the name of the template." ;; Save the selection state for later use. (setq nix-flake-template-repository flake-ref nix-flake-template-name template-name) (let ((proc (start-process "nix flake init" "*nix flake init*" nix-executable "flake" "init" "-t" (concat flake-ref "#" template-name)))) (set-process-sentinel proc (lambda (process _event) (when (eq 'exit (process-status process)) (if (= 0 (process-exit-status process)) (nix-flake-init-post-action) (message "Returned non-zero from nix flake init"))))) proc)) (defun nix-flake-init-post-action () "Perform an post-process action depending on the configuration. See `nix-flake-init-post-action' variable for details." (pcase nix-flake-init-post-action ('open-flake-nix (find-file "flake.nix")) ((pred functionp) (funcall nix-flake-init-post-action)))) ;;;;; Selecting a template (defun nix-flake--templates (flake-ref) "Return a list of templates in FLAKE-REF." (thread-last (nix--process-json "flake" "show" "--json" flake-ref) (alist-get 'templates) (mapcar #'car) (mapcar #'symbol-name))) ;; It might be better to use `transient-define-suffix', but I don't know for ;; sure. (defun nix-flake-init-select-template () "Select a template and initialize a flake." (interactive) (let* ((flake-ref (or nix-flake-template-repository (nix-flake--read-flake-ref "Template repository: "))) (template-name (completing-read (format-message "Select a template from %s: " flake-ref) (nix-flake--templates flake-ref)))) (nix-flake--init flake-ref template-name))) ;;;;; The transient interface ;;;###autoload (autoload 'nix-flake-init "nix-flake" nil t) (transient-define-prefix nix-flake-init-dispatch (&optional flake-ref) "Scaffold a project from a template." [:description "Initialize a flake"] [:description nix-flake--init-source ("=r" nix-flake-init:from-registry) ("=d" nix-flake-init:default-templates)] ["Initialize a flake" ("t" "Select template" nix-flake-init-select-template)] (interactive (list nil)) (when flake-ref (setq nix-flake-template-repository flake-ref)) (transient-setup 'nix-flake-init-dispatch)) ;;;###autoload (defun nix-flake-init () "Run \"nix flake init\" command via a transient interface." (interactive) (let* ((root (locate-dominating-file default-directory ".git")) (default-directory (if (and root (not (file-equal-p root default-directory)) (yes-or-no-p (format-message "The directory %s is not the repository root. Change to %s?" default-directory root))) root default-directory))) (if (file-exists-p "flake.nix") (user-error "The directory already contains a flake") (nix-flake-init-dispatch)))) (provide 'nix-flake) ;;; nix-flake.el ends here