;;; eshell-starship.el --- Starship-like (https://starship.rs) prompt for eshell -*- lexical-binding: t; -*- ;;; Commentary: ;;; Code: (require 'vc) (require 'vc-git) (require 'eshell) (require 'cl-lib) ;;; Configuration options (defgroup eshell-starship nil "Starship-like (starship.rs) prompt for `eshell'." :version "0.0.1" :prefix 'eshell-starship- :group 'eshell) (defvar-local eshell-starship--current-explain-buffer nil "The eshell-starship explain buffer for this eshell buffer.") (defcustom eshell-starship-explain-auto-update t "Non-nil if eshell-starship explain buffers shoul auto-update." :group 'eshell-starship :tag "Auto-update explain buffers" :type 'boolean) (defun eshell-starship--defcustom-setter (sym val) "Set SYM to VAL (using `set-default-toplevel-value'). This will also update all eshell-starship explain buffers that need updating." (set-default-toplevel-value sym val) (dolist (buffer (buffer-list)) (with-current-buffer buffer (when (and (derived-mode-p 'eshell-mode) (buffer-live-p eshell-starship--current-explain-buffer)) (with-current-buffer eshell-starship--current-explain-buffer (when eshell-starship-explain-auto-update (revert-buffer))))))) (defcustom eshell-starship-module-order '("remote" "cwd" "git" "vc" t "cmd-time" "arrow") "The order of modules for eshell-starship. This is a list with each element being a module name. The special value t can appear at most once to denote \"all remaining modules\"." :group 'eshell-starship :tag "Module order" :type '(repeat (choice (const :tag "Remaining modules" t) (string :tag "Module"))) :set 'eshell-starship--defcustom-setter) (defcustom eshell-starship-disabled-modules '() "List of disabled eshell-starship modules." :group 'eshell-starship :tag "Disabled modules" :type '(repeat (string :tag "Module")) :set 'eshell-starship--defcustom-setter) (defcustom eshell-starship-explain-suppress-refresh-messages nil "Weather to suppress messages during eshell-starship explore refreshes." :group 'eshell-starship :tag "Suppress eshell-starship explore refresh messages" :type 'boolean) (defface eshell-starship-icon-face '((t :inherit default)) "Face to use when drawing module icons. Note that the foreground color will be overridden by the module." :group 'eshell-starship :tag "Icon face") (defvar eshell-starship--modules-by (list :extensions (make-hash-table :test 'equal) :dirs (make-hash-table :test 'equal) :files (make-hash-table :test 'equal)) "An alist hash tables that map various fields to lists of modules.") (defun eshell-starship--module-by (field key) "Lookup a list of modules with a FIELD corresponding to KEY. FIELD should be one of the keys in `eshell-starship--modules-by'." (when-let ((table (plist-get eshell-starship--modules-by field))) (gethash key table))) (defvar eshell-starship--extra-module-files () "A list of (NAME IS-DIR MODULE). These represent files that cannot be stored in `eshell-starship--module-by'.") ;;; Module API (defvar eshell-starship-modules (make-hash-table :test 'equal) "List of modules used by eshell-starship.") (defvar-local eshell-starship--module-cache nil "Hash table mapping modules to a list of their last output. The entries of this hash table are of the the form (VALID OUTPUT LAST-TIMES). LAST-TIMES is a list of the 10 last execution times for this module.") (defclass eshell-starship-module () ((name :initarg :name :accessor eshell-starship-module-name :type string :documentation "The name of this module.") (precmd-action :initarg :precmd-action :initform nil :accessor eshell-starship-module-precmd-action :type (or function null) :documentation "A function to run before each command is run.") (postcmd-action :initarg :postcmd-action :initform nil :accessor eshell-starship-module-postcmd-action :type (or function null) :documentation "A function to run after each command is run.") (predicate :initarg :predicate :initform 'ignore :accessor eshell-starship-module-predicate :type function :documentation "A function that should return non-nil if the module should be run.") (files :initarg :files :initform nil :accessor eshell-starship-module-files :type list :documentation "A list of files that indicate that the module should be run.") (dirs :initarg :dirs :initform nil :accessor eshell-starship--module-dirs :type list :documentation "A list of directories that indicate that the module should be run.") (extensions :initarg :extensions :initform nil :accessor eshell-starship-module-extensions :type list :documentation "A list of extensions that indicate that the module should be run.") (prefix :initarg :prefix :initform "" :accessor eshell-starship-module-prefix :type string :documentation "Text to be placed before the module's icon. This is not colored.") (icon :initarg :icon :initform "" :accessor eshell-starship-module-icon :type string :documentation "The modules icon. This is colored.") (postfix :initarg :postfix :initform "" :accessor eshell-starship-module-prefix :type string :documentation "Text to be placed after the module's content. This is not colored.") (color :initarg :color :initform nil :accessor eshell-starship-module-color :type (or null string) :documentation "The color to give the module's icon and main text. Use `list-colors-display' to get a list of some possible values. This can also be nil.") (allow-remote :initarg :allow-remote :initform t :accessor eshell-starship-module-allow-remote-p :type boolean :documentation "Weather the module should be run if `default-directory' is a `file-remote-p'.") (action :initarg :action :initform 'ignore :accessor eshell-starship-module-action :type function :documentation "A function that produces the main text for the module.") (reload-on :initarg :reload-on :initform 'never :accessor eshell-starship-module-reload-on :type (or (member never always cwd) (list-of (member never always cwd))) :documentation "A list of times when this module should be reloaded. Current possible values are: - never (or an empty list): don't ever re-run this module - always: re-run this module every time the prompt is updated - cwd: re-run this module when the CWD changes") (doc :initarg :doc :initform "No documentation provided." :accessor eshell-starship-module-doc :type string :documentation "The documentation for this module.")) (:documentation "Class for eshell-starship modules.")) (defun eshell-starship--remove-module-lookups (name) "Unregistered module named NAME (a string) in `eshell-starship--modules-by'." (cl-loop for table in (cdr eshell-starship--modules-by) by 'cddr do (maphash (lambda (k v) (puthash k (cl-delete-if (lambda (module) (with-slots ((cur-mod-name name)) module (equal cur-mod-name name))) v) table)) table)) (setq eshell-starship--extra-module-files (cl-delete-if (lambda (entry) (with-slots ((cur-mod-name name)) (cl-third entry) (equal cur-mod-name name))) eshell-starship--extra-module-files))) (defun eshell-starship--register-module-lookups (module) "Register MODULE in `eshell-starship--modules-by'." (let ((exts-table (plist-get eshell-starship--modules-by :extensions)) (dirs-table (plist-get eshell-starship--modules-by :dirs)) (files-table (plist-get eshell-starship--modules-by :files))) (with-slots (name dirs extensions files) module (eshell-starship--remove-module-lookups name) (dolist (dir dirs) (if (cl-find ?/ dir) (push (list dir t module) eshell-starship--extra-module-files)) (push module (gethash dir dirs-table))) (dolist (file files) (if (cl-find ?/ file) (push (list file nil module) eshell-starship--extra-module-files) (push module (gethash file files-table)))) (dolist (ext extensions) (push module (gethash ext exts-table)))))) (defun eshell-starship--defmodule-real (name &rest opts) "Do the work of `eshell-starship-defmodule'. NAME is the module name (a symbol) and OPTS is the list of options to pass to the module's constructor." (let ((module (apply 'make-instance 'eshell-starship-module :name (symbol-name name) opts)) (str-name (symbol-name name))) (dolist (buffer (buffer-list)) (with-current-buffer buffer (when (hash-table-p eshell-starship--module-cache) (remhash str-name eshell-starship--module-cache)))) (eshell-starship--register-module-lookups module) ;; This returns module (puthash str-name module eshell-starship-modules))) (defmacro eshell-starship-defmodule (name &rest opts) "Create a new eshell-starship named NAME module with OPTS." (declare (indent defun)) `(eshell-starship--defmodule-real ',name ,@opts)) ;;; Utility functions (cl-defmacro eshell-starship-find-version-function (command pattern &rest format) "Return a version finding function for COMMAND. COMMAND is in the form of (exec args...). The temp buffer that was used to run COMMAND will then have `re-search-forward' run with PATTERN. FORMAT will then be passed verbatim as the arguments to `concat'." (declare (indent defun)) `(lambda () (with-temp-buffer (when (zerop (process-file ,(car command) nil t nil ,@(cdr command))) (goto-char (point-min)) (when (re-search-forward ,pattern nil t) (concat ,@format)))))) (cl-defun eshell-starship-format-span (span &optional (places 0)) "Format SPAN (in seconds) as \"XhXmXs\". The number is rounded to PLACES before being rendered." (let* ((ispan (round span)) (hours (/ ispan 3600)) (mins (% (/ ispan 60) 60)) (secs (mod span 60))) (concat (when (/= hours 0) (format "%dh" hours)) (when (or (/= mins 0) (/= hours 0)) (format "%dm" mins)) (format (format "%%.%dfs" places) secs)))) ;;; CWD Module (defun eshell-starship--replace-home-with-tilda (path) "If PATH beings with $HOME (the environment variable), replace it with ~." (let ((home (getenv "HOME"))) (if (equal home path) "~" (setq home (file-name-as-directory home)) (if (string-prefix-p home path) (concat "~/" (seq-subseq path (length home))) path)))) (defun eshell-starship--limit-path-parts (num path) "Cut PATH down to NUM components. Example: /this/is/a/path 3-> is/a/path" (let ((parts (string-split path "/" t nil))) (concat (when (and (file-name-absolute-p path) (not (equal "~" (car parts))) (<= (length parts) num)) "/") (string-join (last parts num) "/")))) (defun eshell-starship--get-current-dir () "Get dir for `eshell-starship--prompt-function'." (concat (propertize (eshell-starship--limit-path-parts 3 (if-let ((worktree (vc-root-dir)) (parent (file-name-parent-directory worktree))) (file-relative-name default-directory parent) (eshell-starship--replace-home-with-tilda default-directory))) 'face '(:foreground "dark turquoise")) (unless (file-writable-p default-directory) " "))) (eshell-starship-defmodule cwd :predicate 'always :reload-on 'cwd :action 'eshell-starship--get-current-dir :doc "The current working directory.") ;;; Git module (defun eshell-starship--git-parse-status-headers () "Parse the status headers (read from the current buffer). The headers are as described in the porcelain v2 section of the git-status(3) man page. The return value is a list of the form (oid head upstream ahead behind stash)" (let ((oid nil) (head nil) (upstream nil) (ahead nil) (behind nil) (stash nil)) (while (and (char-after) (= (char-after) ?#)) (forward-char 2) (cond ((looking-at "branch\\.oid ") (setq oid (buffer-substring-no-properties (match-end 0) (pos-eol)))) ((looking-at "branch\\.head ") (setq head (buffer-substring-no-properties (match-end 0) (pos-eol)))) ((looking-at "branch\\.upstream ") (setq upstream (buffer-substring-no-properties (match-end 0) (pos-eol)))) ((looking-at "branch\\.ab ") (let ((ab-str (buffer-substring-no-properties (match-end 0) (pos-eol)))) (when (string-match "\\(+[0-9]+\\) \\(-[0-9]+\\)$" ab-str) (setq ahead (string-to-number (match-string 1 ab-str)) behind (string-to-number (match-string 2 ab-str)))))) ((looking-at "stash ") (setq stash (string-to-number (buffer-substring-no-properties (match-end 0) (pos-eol)))))) (forward-line)) (list oid head upstream (or ahead 0) (or behind 0) stash))) (defun eshell-starship--git-interpret-file-status (x y) "Return the prompt character for the status X and Y. A description of X and Y can be found in the git-status(3) man page." (cond ((or (= x ?D) (= y ?D)) ?) ((or (= x ?R) (= y ?R)) ?») ((= y ?M) ?!) ((or (= x ?A) (= x ?M)) ?+))) (defun eshell-starship--git-interpret-branch-status (ahead behind) "Get the status char for the current branch and its remote. AHEAD should evaluate to t if the current branch is ahead of its remote, and BEHIND should evaluate to t if the current branch is behind its remote." (cond ((and ahead behind) "󰹺") (ahead "󰜷") (behind "󰜮"))) (defun eshell-starship--git-file-status (stash ahead behind) "Get the file status string for the git prompt module. STASH should be t if there is current stashed data stash. AHEAD and BEHIND should be as for `eshell-starship--git-interpret-branch-status'." (let ((merge-conflicts nil) (status-chars nil)) (while (not (eobp)) (cond ((= (char-after) ??) (push ?? status-chars)) ((= (char-after) ?u) (setq merge-conflicts t)) ((or (= (char-after) ?1) (= (char-after) ?2)) (push (eshell-starship--git-interpret-file-status (char-after (+ 2 (point))) (char-after (+ 3 (point)))) status-chars))) (forward-line)) (concat (eshell-starship--git-interpret-branch-status (not (zerop ahead)) (not (zerop behind))) (when merge-conflicts "=") (when stash "$") (apply 'string (sort (seq-uniq status-chars) #'<))))) (defun eshell-starship--git-current-operation () "Return the current git operation. For example, a revert. If there is no current operation, return nil." (let ((git-dir (expand-file-name ".git" (vc-git-root default-directory)))) (cond ((file-exists-p (expand-file-name "rebase-apply/applying" git-dir)) "AM") ((file-exists-p (expand-file-name "rebase-apply/rebasing" git-dir)) "REBASE") ((file-exists-p (expand-file-name "rebase-apply" git-dir)) "AM/REBASE") ((file-exists-p (expand-file-name "rebase-merge" git-dir)) "REBASING") ((file-exists-p (expand-file-name "CHERRY_PICK_HEAD" git-dir)) "CHERRY-PICKING") ((file-exists-p (expand-file-name "MERGE_HEAD" git-dir)) "MERGING") ((file-exists-p (expand-file-name "BISECT_LOG" git-dir)) "BISECTING") ((file-exists-p (expand-file-name "REVERT_HEAD" git-dir)) "REVERTING")))) (defun eshell-starship--git-status () "Return the text for the git module for `eshell-starship--prompt-function'." (with-temp-buffer (when (zerop (vc-git-command t nil nil "status" "--porcelain=v2" "--branch" "--show-stash")) (goto-char (point-min)) (cl-destructuring-bind (oid head _upstream ahead behind stash) (eshell-starship--git-parse-status-headers) (let* ((file-status (eshell-starship--git-file-status stash ahead behind)) (operation (eshell-starship--git-current-operation)) (output (concat (if (string= "(detached)" head) (propertize (concat " (" (substring oid 0 7) ")") 'face '(:foreground "lawn green")) (propertize (concat head) 'face '(:foreground "medium purple"))) (unless (string-empty-p file-status) (propertize (concat " [" file-status "]") 'face '(:foreground "red"))) (when operation (concat " (" (propertize operation 'face '(:inherit bold :foreground "yellow")) ")"))))) (unless (zerop (length output)) output)))))) (eshell-starship-defmodule git :predicate (lambda () (eq (vc-responsible-backend default-directory t) 'Git)) :color "medium purple" :icon "󰊢 " :reload-on 'always :action 'eshell-starship--git-status :doc "The working directory's status as a git repository.") ;;; Non-git VC module (defun eshell-starship--vc-status () "Get vc status for `eshell-starship--prompt-function'." (when-let ((backend (vc-responsible-backend default-directory t)) ((not (eq backend 'Git)))) (downcase (symbol-name backend)))) (eshell-starship-defmodule vc :predicate 'always :allow-remote nil :reload-on 'always :color "purple" :icon " " :action 'eshell-starship--vc-status :doc "The working directory's version control status (other than git).") ;;; Timer module (defvar-local eshell-starship--last-start-time nil "Start time of last eshell command.") (defvar-local eshell-starship--last-end-time nil "End time of last eshell command.") (defun eshell-starship--last-command-time () "Return the prompt component for the time of the last command." (prog1 (and eshell-starship--last-start-time eshell-starship--last-end-time ;; this must be here (not in a predicate) to ensure these values get ;; cleared (<= 3 (- eshell-starship--last-end-time eshell-starship--last-start-time)) (eshell-starship-format-span (- eshell-starship--last-end-time eshell-starship--last-start-time))) (setq eshell-starship--last-start-time nil eshell-starship--last-end-time nil))) (eshell-starship-defmodule cmd-time :predicate 'always :prefix "took " :color "gold1" :reload-on 'always :precmd-action (lambda () (setq eshell-starship--last-start-time (float-time))) :postcmd-action (lambda () (setq eshell-starship--last-end-time (float-time))) :action 'eshell-starship--last-command-time :doc "The amount of time it took the last command to execute.") ;;; Language modules (eshell-starship-defmodule cc :extensions '("c" "h") :prefix "via " :icon "C " :color "spring green" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("cc" "-v") "^\\([-a-zA-Z]+\\) version \\([0-9]+\\.[0-9]+\\.[0-9]+\\)" "v" (match-string 2) "-" (match-string 1)) :doc "Your C compiler version.") (eshell-starship-defmodule c++ :extensions '("cpp" "cc" "cxx" "hpp" "hh" "hxx") :prefix "via " :icon " " :color "royal blue" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("cpp" "--version") "\\(GCC\\|clang\\).+?\\([0-9]+\\.[0-9]+\\.[0-9]+\\)" "v" (match-string 2) "-" (downcase (match-string 1))) :doc "Your C++ compiler version.") (eshell-starship-defmodule rust :extensions '("rs") :files '("Cargo.toml") :prefix "via " :icon "🦀 " :color "red" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("rustc" "--version") "^rustc \\([0-9]+\\.[0-9]+\\.[0-9]+\\)" "v" (match-string 1)) :doc "Your Rust compiler version.") (eshell-starship-defmodule cmake :files '("CMakeLists.txt" "CMakeCache.txt") :extensions '("cmake") :prefix "via " :icon "󰔶 " :color "blue" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("cmake" "--version") "cmake version \\([0-9]+\\.[0-9]+\\.[0-9]+\\)" "v" (match-string 1)) :doc "Your CMake version.") (require 'inf-lisp nil t) (when (featurep 'inf-lisp) (eshell-starship-defmodule common-lisp :extensions '("asd" "lisp") :prefix "via " :icon " " :color "green yellow" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function (inferior-lisp-program "--version") "[a-zA-Z]+ [0-9.]+" (match-string 0)) :doc "Your current inferior-lisp program.")) (eshell-starship-defmodule elisp :extensions '("el" "elc" "eln") :prefix "via " :icon " " :color "dark orchid" :allow-remote nil :reload-on 'never :action (lambda () emacs-version) :doc "The current emacs-version.") (eshell-starship-defmodule java :extensions '("java" "class" "gradle" "jar" "clj" "cljc") :files '("pom.xml" "build.gradle.kts" "build.sbt" ".java-version" "deps.edn" "project.clj" "build.boot" ".sdkmanrc") :prefix "via " :icon "☕ " :color "dark red" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("java" "-version") "version \"\\([0-9]+\\)\"" "v" (match-string 1)) :doc "Your Java version.") (eshell-starship-defmodule zig :extensions '("zig") :prefix "via " :icon "↯ " :color "yellow" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("zig" "version") ".+" "v" (match-string 0)) :doc "Your Zig version.") (eshell-starship-defmodule python :extensions '("py" "ipynb") :files '(".python-version" "Pipfile" "__init__.py" "pyproject.toml" "requirements.txt" "setup.py" "tox.ini" "pixi.toml") :prefix "via " :icon "🐍 " :color "#CECB00" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("python" "--version") "^Python \\([0-9.]+\\)" "v" (match-string 1)) :doc "Your current system-wide Python version.") (eshell-starship-defmodule php :extensions '("php")' :files '("composer.json" ".php-version") :prefix "via " :icon "🐘 " :color "#AFAFFF" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("php" "--version") "^PHP \\([0-9.]+\\)" "v" (match-string 1)) :doc "Your current PHP version.") (eshell-starship-defmodule node :extensions '("js" "mjs" "cjs" "ts" "mts" "cts") :files '("package.json" ".node-version" ".nvmrc") :dirs '("node_modules") :prefix "via " :icon " " :color "green" :allow-remote nil :reload-on 'cwd :action (eshell-starship-find-version-function ("node" "--version") ".+" (match-string 0)) :doc "Your current NodeJS version.") ;;; Misc modules (eshell-starship-defmodule remote :icon "🌐" :color "light blue" :predicate (lambda () (file-remote-p default-directory)) :doc "A small icon if the working directory is remote.") (eshell-starship-defmodule arrow :predicate 'always :reload-on 'always :action (lambda () (concat (propertize "\n" 'read-only t 'rear-nonsticky t) (propertize "❯ " 'face `(:foreground ,(if (= eshell-last-command-status 0) "lime green" "red")) 'rear-nonsticky t))) :doc "An arrow that appears next to where you type.") ;;; Driver code (defun eshell-starship-clear-cache (&rest flags) "Clear each cache entry with a \\=:reload-on of FLAGS. If any of flags is t, clear all caches." (interactive '(t) eshell-starship) (cl-loop with force-clear = (member t flags) for module being the hash-values of eshell-starship-modules do (with-slots (name reload-on) module (when-let (((or force-clear (cl-intersection (ensure-list reload-on) flags))) (cur-entry (gethash name eshell-starship--module-cache))) (setf (car cur-entry) nil))))) (defun eshell-starship--cwd-clear-caches () "Clear caches that should be cleared on cwd for eshell-starship." (eshell-starship-clear-cache 'cwd)) (defun eshell-starship--permute-extension (ext) "Permute EXT for lookup up modules. That is, if EXT is \"pkg.tar.gz\", this will return \(\"pkg.tar.gz\" \"tar.gz\" \"gz\")." (cl-maplist (lambda (parts) (mapconcat 'identity parts ".")) (string-split ext "\\."))) (defun eshell-starship--file-name-extension (name) "Return the extension for a file name NAME." (if-let ((start (if (string-prefix-p "." name) 1 0)) (idx (cl-position ?. name :start start :test '=))) (substring name (1+ idx)) "")) (defun eshell-starship--modules-for-dir (dir) "Return a list of modules that are applicable to DIR." (seq-uniq (nconc (mapcan (lambda (entry) (let ((name (car entry)) (is-dir (eq t (file-attribute-type (cdr entry))))) (if is-dir (copy-sequence (eshell-starship--module-by :dirs name)) (apply 'nconc (eshell-starship--module-by :files name) (mapcar (lambda (ext) (copy-sequence (eshell-starship--module-by :extensions ext))) (eshell-starship--permute-extension (eshell-starship--file-name-extension name))))))) (directory-files-and-attributes dir nil nil t)) (let ((default-directory dir)) (cl-loop for (name is-dir module) in eshell-starship--extra-module-files when (and is-dir (file-directory-p name)) collect module when (and (not is-dir) (file-exists-p name)) collect module)) (let ((default-directory dir)) (cl-loop for module being the hash-values of eshell-starship-modules for predicate = (eshell-starship-module-predicate module) when (funcall predicate) collect module))) 'eq)) (defun eshell-starship--propertize-face (str append &rest faces) "Copy STR and add FACES to its text properties. This uses `add-face-text-property' internally, so it will add to existing `face' properties. If STR is nil, return an empty string. If APPEND, give priority to existing faces." (if (not str) "" (let ((copy (copy-sequence str))) (dolist (face faces copy) (add-face-text-property 0 (length copy) face append copy))))) (defun eshell-starship--execute-module (module) "Run the module MODULE and return its output. Also cache the time it took to run it and its output." (with-slots (name action prefix postfix icon color) module (let ((oldtimes (cl-third (gethash name eshell-starship--module-cache))) start-time result end-time) (setq start-time (float-time) result (funcall action) end-time (float-time)) (when-let ((result) (output (concat prefix (eshell-starship--propertize-face icon t (when color (list (list :foreground color))) 'eshell-starship-icon-face) (if color (eshell-starship--propertize-face result t (list :foreground color)) result) postfix))) (puthash name (list t output (cons (- end-time start-time) (take 9 oldtimes))) eshell-starship--module-cache) output)))) (defvar-local eshell-starship--last-prompt-modules nil "The list of the modules that where shown in the last prompt.") (defun eshell-starship--execute-modules () "Execute all the modules in `eshell-starship-modules'. Return a hash table mapping module names to their output." (setq eshell-starship--last-prompt-modules nil) (cl-loop with output = (make-hash-table :test 'equal) for module in (eshell-starship--modules-for-dir default-directory) for name = (eshell-starship-module-name module) for reload-on = (ensure-list (eshell-starship-module-reload-on module)) for cache-entry = (gethash name eshell-starship--module-cache) do (if (and (not (member 'always reload-on)) (car cache-entry)) (puthash name (cl-second cache-entry) output) (puthash name (eshell-starship--execute-module module) output)) do (push module eshell-starship--last-prompt-modules) finally (maphash (lambda (k v) (unless v (remhash k output))) output) finally return output)) (defun eshell-starship--run-module-precmd-actions () "Run the pre-command action for each module." (cl-loop for module being the hash-values of eshell-starship-modules for precmd-action = (eshell-starship-module-precmd-action module) when precmd-action do (funcall precmd-action))) (defun eshell-starship--run-module-postcmd-actions () "Run the post-command action for each module." (cl-loop for module being the hash-values of eshell-starship-modules for postcmd-action = (eshell-starship-module-postcmd-action module) when postcmd-action do (funcall postcmd-action))) (defun eshell-starship--build-module-string () "Build a space-separated string of module outputs." (let ((output (eshell-starship--execute-modules)) pre post found-rest) (dolist (cur-name (mapcar (lambda (name) (if (and (not (eq name t)) (symbolp name)) (symbol-name name) name)) eshell-starship-module-order)) (cond ((and (eq cur-name t) found-rest) (warn "t appears more than once in `eshell-starship-module-order")) ((eq cur-name t) (setq found-rest t)) ((not (gethash cur-name output)) ;; skip ) (found-rest (push (gethash cur-name output) post) (remhash cur-name output)) (t (push (gethash cur-name output) pre) (remhash cur-name output)))) (mapconcat 'identity (nconc (nreverse pre) (hash-table-values output) (nreverse post)) " "))) (defun eshell-starship--render-prompt () "Actually produce the prompt." (concat (unless (< (line-number-at-pos) 3) "\n") (eshell-starship--build-module-string))) (defvar-local eshell-starship--last-prompt-info nil "A list of the last prompt and the time it took to render it.") (defun eshell-starship--prompt-function () "Function for `eshell-prompt-function'." (let (start-time prompt end-time) (setq start-time (float-time) prompt (eshell-starship--render-prompt) end-time (float-time) eshell-starship--last-prompt-info (list prompt (- end-time start-time))) (when (buffer-live-p eshell-starship--current-explain-buffer) (with-current-buffer eshell-starship--current-explain-buffer (when eshell-starship-explain-auto-update (let ((eshell-starship-explain-suppress-refresh-messages t)) (revert-buffer))))) prompt)) (defvar-local eshell-starship--restore-state nil "State of various variables set by `eshell-starship-prompt-mode'.") (defvar-local eshell-starship--explain-eshell-buffer nil "The eshell buffer backing this eshell-starship-explain buffer.") (defun eshell-starship--enable () "Enable eshell-starship." (setq-local eshell-starship--restore-state (buffer-local-set-state eshell-prompt-function 'eshell-starship--prompt-function eshell-prompt-regexp "^❯ " eshell-highlight-prompt nil) eshell-starship--module-cache (make-hash-table :test 'equal)) (add-hook 'eshell-pre-command-hook #'eshell-starship--run-module-precmd-actions nil t) (add-hook 'eshell-post-command-hook #'eshell-starship--run-module-postcmd-actions nil t) (add-hook 'eshell-directory-change-hook #'eshell-starship--cwd-clear-caches nil t)) (defun eshell-starship--disable () "Disable eshell-starship." (when eshell-starship--current-explain-buffer (with-current-buffer eshell-starship--current-explain-buffer (setq eshell-starship--explain-eshell-buffer nil))) (setq-local eshell-starship--module-cache nil eshell-starship--current-explain-buffer nil) (buffer-local-restore-state eshell-starship--restore-state) (remove-hook 'eshell-pre-command-hook #'eshell-starship--run-module-precmd-actions t) (remove-hook 'eshell-post-command-hook #'eshell-starship--run-module-postcmd-actions t) (remove-hook 'eshell-directory-change-hook #'eshell-starship--cwd-clear-caches t)) ;;;###autoload (define-minor-mode eshell-starship-prompt-mode "Minor mode to make eshell prompts look like starship (https://starship.rs)." :global nil :init-value nil :interactive (eshell-mode) (if eshell-starship-prompt-mode (eshell-starship--enable) (eshell-starship--disable))) ;;; Explain buffer (defface eshell-starship--heading '((t (:height 1.2 :weight bold) )) "Face for showing headings in `eshell-starship-explain' buffers.") (defun eshell-starship--insert-prompt-string (str &optional prefix first-prefix) "Insert STR, a prompt string, into the current buffer. This just cleans up STR a bit before inserting it. Also, if PREFIX is non-nil, it will be inserted at the start of each line. If FIRST-PREFIX is non-nil, it will be used specially as the first line's prefix." (cl-loop with first-line = t with blank-count = 0 with found-start = nil for line in (string-lines str) for empty = (zerop (length line)) while line do (if empty (when found-start (cl-incf blank-count)) (setq found-start t) (dotimes (_ blank-count) (insert "\n")) (if (and first-line first-prefix) (progn (insert first-prefix) (setq first-line nil)) (insert prefix)) (insert line) (insert "\n")))) (defun eshell-starship--explain-insert-module (module &optional no-output) "Insert information about MODULE at point. If NO-OUTPUT is non-nil, don't insert the modules previous output." (with-slots (name doc) module (let ((bullet-char (if (char-displayable-p ?\•) ?\• ?\-)) (cache-entry (gethash name (buffer-local-value 'eshell-starship--module-cache eshell-starship--explain-eshell-buffer)))) (insert (format " %c %s - %s\n" bullet-char (propertize name 'face 'font-lock-keyword-face) doc)) (unless no-output (unless (member module (buffer-local-value 'eshell-starship--last-prompt-modules eshell-starship--explain-eshell-buffer)) (insert " (This module is hidden.)\n")) (if (not (cl-first cache-entry)) (insert " This module has no cached output.\n") (insert " Last output was:\n") (eshell-starship--insert-prompt-string (cl-second cache-entry) " " " \"") (forward-line -1) (end-of-line) (insert "\"") (forward-line) (insert (format " It took %s.\n" (eshell-starship-format-span (car (cl-third cache-entry)) 3)))) (insert "\n"))))) (defun eshell-starship--explain-insert-enabled () "Insert an explanation of enabled modules at point." (let ((rest-modules (copy-hash-table eshell-starship-modules)) rest-point) (dolist (cur-name eshell-starship-module-order) (unless (cl-member cur-name eshell-starship-disabled-modules :test 'equal) (if (eq cur-name t) (setq rest-point (point)) (when-let ((module (gethash cur-name eshell-starship-modules))) (eshell-starship--explain-insert-module module) (remhash cur-name rest-modules))))) (save-excursion (goto-char rest-point) (cl-loop for module being the hash-values of rest-modules using (hash-keys name) unless (cl-member name eshell-starship-disabled-modules :test 'equal) do (eshell-starship--explain-insert-module module))))) (defun eshell-starship--explain-format-buffer () "Fill the current buffer with content for `eshell-starship-explain'." (unless (buffer-live-p eshell-starship--explain-eshell-buffer) (error "Parent Eshell buffer is gone (or no longer using eshell-starship)")) (erase-buffer) (cl-flet ((heading (txt) (propertize txt 'face 'eshell-starship--heading))) (cl-destructuring-bind (&optional last-prompt last-time) (buffer-local-value 'eshell-starship--last-prompt-info eshell-starship--explain-eshell-buffer) (when (and last-prompt last-time) (insert "The last prompt was:\n") (eshell-starship--insert-prompt-string last-prompt " ") (insert (format "\nIt was rendered in %s.\n\n" (eshell-starship-format-span last-time 3))))) (insert (heading "The following modules are enabled:\n")) (eshell-starship--explain-insert-enabled) (if (null eshell-starship-disabled-modules) (insert (heading "There are no disabled modules.")) (insert (heading "The following modules are disabled:\n")) (dolist (name eshell-starship-disabled-modules) (when-let ((module (gethash name eshell-starship-modules))) (eshell-starship--explain-insert-module module t))) ;; get rid of newline (delete-char -1)))) (defun eshell-starship--explain-revert (_ignore-auto _noconfirm) "Revert function for eshell-starship explain buffers. _IGNORE-AUTO and _NOCONFIRM are ignored." (let ((save (point)) (inhibit-read-only t)) (eshell-starship--explain-format-buffer) (goto-char save)) (unless eshell-starship-explain-suppress-refresh-messages (message "Refreshed eshell-starship explain buffer"))) ;;;###autoload (defun eshell-starship-explain-toggle-auto-update-mode (&optional arg) "Toggle `eshell-starship-explain-auto-update' in the current buffer. If ARG is negative, disable it. If ARG is positive, enable it. Otherwise, toggle it." (interactive "P" eshell-starship-explain-mode) (unless (derived-mode-p 'eshell-starship-explain-mode) (error "Not an eshell-starship explain buffer")) (if (not arg) (cl-callf not eshell-starship-explain-auto-update) (let ((num (prefix-numeric-value arg))) (setq eshell-starship-explain-auto-update (<= 0 num)))) (when eshell-starship-explain-auto-update (revert-buffer)) (force-mode-line-update) (message "%s auto-updating." (if eshell-starship-explain-auto-update "Enabled" "Disabled"))) ;;;###autoload (defvar-keymap eshell-starship-explain-mode-map :doc "Keymap for `eshell-starship-explain-mode'." :parent special-mode-map :suppress t "a" #'eshell-starship-explain-toggle-auto-update-mode "r" #'revert-buffer) (define-derived-mode eshell-starship-explain-mode nil "Eshell-Starship Explain" "Major mode for `eshell-starship-explain' buffers." :group 'eshell-starship :interactive nil (setq-local mode-name '("Eshell-Starship Explain" (eshell-starship-explain-auto-update "/a")) display-line-numbers nil revert-buffer-function 'eshell-starship--explain-revert)) ;;;###autoload (defun eshell-starship-setup-evil-keybindings () "Setup keybindings for `evil-mode' for eshell-starship." (require 'evil) (when (fboundp 'evil-define-key*) (evil-define-key* '(motion normal) eshell-starship-explain-mode-map "a" #'eshell-starship-explain-toggle-auto-update-mode "r" #'revert-buffer))) ;;;###autoload (defun eshell-starship-explain () "Show some information about the current prompt." (interactive nil eshell-mode) (unless (derived-mode-p 'eshell-mode) (error "Current buffer is not in eshell-mode. Nothing to explain")) (let ((eshell-buffer (current-buffer)) (explain-buffer (get-buffer-create "*Eshell-Starship Explain*"))) (dolist (buffer (buffer-list)) (with-current-buffer buffer (cond ((eq buffer eshell-buffer) (setq eshell-starship--current-explain-buffer explain-buffer)) ((derived-mode-p 'eshell-mode) (setq eshell-starship--current-explain-buffer nil))))) (with-current-buffer explain-buffer (unless (derived-mode-p 'eshell-starship-explain-mode) (eshell-starship-explain-mode)) (setq eshell-starship--explain-eshell-buffer eshell-buffer) (save-excursion (let ((inhibit-read-only t)) (eshell-starship--explain-format-buffer))) (pop-to-buffer (current-buffer))))) (provide 'eshell-starship) ;;; eshell-starship.el ends here