;;; dirvish-fd.el --- find-dired alternative using fd  -*- lexical-binding: t; -*-

;; Copyright (C) 2021-2025 Alex Lu
;; Author : Alex Lu <https://github.com/alexluigit>
;; Keywords: files, convenience
;; Homepage: https://github.com/alexluigit/dirvish
;; SPDX-License-Identifier: GPL-3.0-or-later

;;; Commentary:

;; `fd' integration for Dirvish.

;;; Code:

(require 'dirvish)
(require 'transient)

(defcustom dirvish-fd-switches ""
  "Fd arguments inserted before user input."
  :type 'string :group 'dirvish)

(defun dirvish-fd--find-fd-program (&optional remote)
  "Find fd programm on a local or REMOTE host ."
  (let ((fd (executable-find "fd" remote))
        (fdfind (executable-find "fdfind" remote)))
    (cond (fd fd)
          (fdfind fdfind)
          (t nil))))

(defcustom dirvish-fd-program
  (dirvish-fd--find-fd-program)
  "The default fd program."
  :type 'string :group 'dirvish)

(defcustom dirvish-fd-setup-hook nil
  "Functions called after the `fd` process exits successfully."
  :type 'hook :group 'dirvish)

(defun dirvish-fd--find-gnu-ls (&optional remote)
  "Find ls from gnu coreutils on a local or REMOTE host ."
  (let* ((ls (executable-find "ls" remote))
         (gls (executable-find "gls" remote))
         (idp (executable-find insert-directory-program remote))
         (ls-is-gnu? (and ls (= 0 (process-file ls nil nil nil "--version"))))
         (idp-is-gnu-ls?
          (and idp (= 0 (process-file idp nil nil nil "--version")))))
    (cond
     ;; just use GNU ls if found
     (ls-is-gnu? ls)
     ;; use insert-directory-program if it points to GNU ls
     (idp-is-gnu-ls? insert-directory-program)
     ;; heuristic: GNU ls is often installed as gls by Homebrew on Mac
     ((and (eq system-type 'darwin) gls) gls)
     ;; fallback: use insert-directory-program, but warn the user that it may not be compatible
     (t (warn "`dirvish-fd' requires `ls' from GNU coreutils, please install it")
        insert-directory-program))))

(defcustom dirvish-fd-ls-program
  (dirvish-fd--find-gnu-ls)
  "Listing program for `fd'."
  :type '(string :tag "Listing program, such as `ls'") :group 'dirvish)

(defcustom dirvish-fd-header-line-format '(:left (fd-info) :right (fd-status))
  "Header line format for `dirvish-fd'."
  :group 'dirvish :type 'plist)

(defun dirvish-fd--ensure-fd (remote)
  "Return fd executable on REMOTE or localhost.
Raise an error if fd executable is not available."
  (or (and remote (dirvish-fd--find-fd-program remote)) dirvish-fd-program
      (user-error "`dirvish-fd' requires `fd', please install it")))

(defun dirvish-fd--apply-switches ()
  "Apply fd SWITCHES to current buffer."
  (interactive)
  (cl-loop with (re . args) = nil
           for arg in (transient-args transient-current-command)
           if (string-prefix-p "--and=" arg) do (push arg re)
           else do (push arg args)
           finally do (dirvish-fd--argparser re args))
  (revert-buffer))

(transient-define-infix dirvish-fd--extensions-switch ()
  :description "Filter results by file extensions"
  :class 'transient-option
  :argument "--extension="
  :multi-value 'repeat)

(transient-define-infix dirvish-fd--exclude-switch ()
  :description "Exclude files/dirs that match the glob pattern"
  :class 'transient-option
  :argument "--exclude="
  :multi-value 'repeat)

(transient-define-infix dirvish-fd--search-pattern-infix ()
  :description "Change search patterns"
  :class 'transient-option
  :argument "--and="
  :multi-value 'repeat)

;;;###autoload (autoload 'dirvish-fd-switches-menu "dirvish-fd" nil t)
(transient-define-prefix dirvish-fd-switches-menu ()
  "Setup fd switches."
  :init-value (lambda (o) (let ((args (dirvish-prop :fd-info)))
                       (oset o value (append (cadr args) (cddr args)))))
  [:description
   (lambda () (dirvish--format-menu-heading
          "Setup FD Switches"
          "Ignore Range [by default ignore ALL]
  VCS: .gitignore + .git/info/exclude + $HOME/.config/git/ignore
  ALL: VCS + .ignore + .fdignore + $HOME/.config/fd/ignore"))
   ["File types (multiple types can be included)"
    (3 "f" " Search for regular files" "--type=file")
    (3 "d" " Search for directories" "--type=directory")
    (3 "l" " Search for symbolic links" "--type=symlink")
    (3 "s" " Search for sockets" "--type=socket")
    (3 "p" " Search for named pipes" "--type=pipe")
    (3 "x" " Search for executable" "--type=executable")
    (3 "e" " Search for empty files or directories" "--type=empty")
    ""
    "Toggles"
    (3 "-H" "Include hidden files|dirs in the results" "--hidden")
    (3 "-I" "Show results from ALL" "--no-ignore")
    (4 "iv" "Show results from VCS" "--no-ignore-vcs")
    (5 "ip" "Show results from .gitignore in parent dirs" "--no-ignore-parent")
    (3 "-s" "Perform a case-sensitive search" "--case-sensitive")
    (4 "-g" "Perform a glob-based (rather than regex-based) search" "--glob")
    (4 "-F" "Treat the pattern as a literal string" "--fixed-strings")
    (4 "-L" "Traverse symbolic links" "--follow")
    (4 "-p" "Let the pattern match against the full path" "--full-path")
    (5 "mr" "Maximum number of search results" "--max-results")
    (5 "mt" "Do not descend into a different file systems" "--mount")
    (5 "P" " Do not traverse into matching directories" "--prune")
    ""
    "Options"
    (4 "-e" dirvish-fd--extensions-switch)
    (4 "-E" dirvish-fd--exclude-switch)
    (4 "-D" "Max level for directory traversing" "--max-depth=")
    (5 "-d" "Only show results starting at the depth" "--mix-depth=")
    (5 "gd" "Only show results starting at the exact given depth" "--exact-depth=")
    (5 "if" "Add a custom ignore-file in '.gitignore' format" "--ignore-file="
       :reader (lambda (_prompt _init _hist) (read-file-name "Choose ignore file: ")))
    (5 "-S" "Limit results based on the size of files" "--size="
       :reader (lambda (_prompt _init _hist)
                 (read-string "Input file size using the format <+-><NUM><UNIT> (eg. +100m): ")))
    (5 "cn" "Filter results based on the file mtime newer than" "--changed-within="
       :reader (lambda (_prompt _init _hist)
                 (read-string "Input a duration (10h, 1d, 35min) or a time point (2018-10-27 10:00:00): ")))
    (5 "co" "Filter results based on the file mtime older than" "--changed-before="
       :reader (lambda (_prompt _init _hist)
                 (read-string "Input a duration (10h, 1d, 35min) or a time point (2018-10-27 10:00:00): ")))
    (6 "-o" "Filter files by their user and/or group" "--owner="
       :reader (lambda (_prompt _init _hist)
                 (read-string "user|uid:group|gid - eg. john, :students, !john:students ('!' means to exclude files instead): ")))
    ""
    "Actions"
    ("r" dirvish-fd--search-pattern-infix)
    ("RET" "Rerun" dirvish-fd--apply-switches)]])

(defun dirvish-fd--argparser (re args)
  "Parse fd args to a list of flags from ARGS and search regexp RE."
  (let* ((globp (member "--glob" args))
         (casep (member "--case-sensitive" args))
         (ign (cond ((member "--no-ignore" args) "no")
                    ((member "--no-ignore-vcs" args) "no_vcs")
                    (t "all")))
         (status (propertize " ●  " 'face 'dirvish-proc-running))
         comp types exts exc)
    (dolist (arg args)
      (cond ((string-prefix-p "--type=" arg) (push (substring arg 7) types))
            ((string-prefix-p "--extension=" arg) (push (substring arg 12) exts))
            ((string-prefix-p "--exclude=" arg) (push (substring arg 10) exc))))
    (dolist (r re) (push (substring r 6) comp))
    (setq types (mapconcat #'concat types ","))
    (setq exts (mapconcat #'concat exts ","))
    (setq exc (mapconcat #'concat exc ","))
    (setq comp (mapconcat #'concat comp ","))
    (dirvish-prop :fd-info
      (cons (list comp globp casep ign types exts exc status) (cons re args)))))

(dirvish-define-mode-line fd-info
  "Return a formatted string showing the actual fd command line arguments."
  (pcase-let ((`(,re ,globp ,casep ,ign-range ,types ,exts ,excludes ,_)
               (car (dirvish-prop :fd-info)))
              (face (if (dirvish--selected-p) 'dired-header 'dirvish-inactive)))
    (format "  🔍 ⋗ %s [ %s \"%s\" | %s %s | %s %s | %s %s | %s %s | %s %s ]"
            (propertize
             (abbreviate-file-name (directory-file-name default-directory))
             'face 'dired-directory)
            (propertize (if globp "glob:" "regex:") 'face face)
            (propertize (or re "")
                        'face 'font-lock-regexp-grouping-construct)
            (propertize "type:" 'face face)
            (propertize (if (equal types "") "all" types)
                        'face 'font-lock-variable-name-face)
            (propertize "case:" 'face face)
            (propertize (if casep "sensitive" "smart")
                        'face 'font-lock-type-face)
            (propertize "ignore:" 'face face)
            (propertize ign-range 'face 'font-lock-comment-face)
            (propertize "exts:" 'face face)
            (propertize (if (equal exts "") "all" exts)
                        'face 'font-lock-string-face)
            (propertize "excludes:" 'face face)
            (propertize (if (equal excludes "") "none" excludes)
                        'face 'font-lock-variable-name-face))))

(dirvish-define-mode-line fd-status
  "Status and time took by last fd search."
  (car (last (car (dirvish-prop :fd-info)))))

(defun dirvish-fd--proc-filter (proc string)
  "Filter for output STRING of `dirvish-fd''s process PROC."
  (when-let* (((buffer-name (process-buffer proc)))
              (target (process-get proc 'target)) ((buffer-live-p target)))
    (with-current-buffer target
      (save-excursion
        (save-restriction
          (widen)
          (goto-char (cdar dired-subdir-alist)) (goto-char (dired-subdir-max))
          (cl-loop
           with buffer-read-only = nil
           with (_ regexps case-fold-search) = (dirvish-prop :narrow-info)
           with string = (concat (process-get proc 'tail) string)
           with splits = (split-string string "\n" t)
           with tail = (car (last splits))
           with comp? = (string-suffix-p "\n" string)
           for file in (if comp? splits (butlast splits))
           for f-beg = (string-match " ./" file)
           for f-name = (substring file (+ f-beg 3))
           for f-line = (concat "  " (substring file 0 f-beg) " " f-name "\n")
           do (if (not regexps) (insert f-line)
                (cl-loop for re in regexps
                         unless (string-match re f-name) return nil
                         finally do (insert f-line)))
           finally do (process-put proc 'tail (unless comp? tail))))))))

(defun dirvish-fd--proc-sentinel (proc status)
  "Sentinel for `dirvish-fd' process PROC and its STATUS."
  (when-let* (((buffer-live-p (process-buffer proc)))
              (took (float-time (time-since (process-get proc 'start))))
              (target (process-get proc 'target)) ((buffer-live-p target)))
    (setq took (if (< took 1.0) (format "%s ms" (round took 0.001))
                 (format "%s secs" (/ (round took 0.001) 1000.0))))
    (with-current-buffer target
      (setf (car (last (car (dirvish-prop :fd-info))))
            (cond ((string-prefix-p "killed" status)
                   (propertize " ●  " 'face 'dirvish-proc-failed))
                  ((string-prefix-p "finished" status)
                   (propertize (format "%s ●  " took)
                               'face 'dirvish-proc-finished))
                  (t (propertize " ●  " 'face 'dirvish-proc-failed))))
      (run-hooks 'dirvish-fd-setup-hook))
    (force-mode-line-update t)))

(defun dirvish-fd--start-proc ()
  "Start fd process."
  (let* ((remote (file-remote-p default-directory))
         (fd (dirvish-fd--ensure-fd remote))
         (ls (dirvish-fd--find-gnu-ls remote))
         (fd-args (dirvish-prop :fd-info))
         (buf (get-buffer-create "*dirvish-fd*"))
         process-connection-type proc)
    (when-let* ((op (get-buffer-process buf))) (delete-process op))
    (setq proc (apply #'start-file-process "fd" buf
                      `(,fd "--color=never" ,@(cddr fd-args) ,@(cadr fd-args)
                            "--exec-batch" ,ls
                            ,@(or (split-string dired-actual-switches) "")
                            "--quoting-style=literal" "--directory")))
    (set-process-filter proc #'dirvish-fd--proc-filter)
    (set-process-sentinel proc #'dirvish-fd--proc-sentinel)
    (set-process-query-on-exit-flag proc nil)
    (process-put proc 'start (float-time))
    (process-put proc 'target (current-buffer))))

(defun dirvish-fd-noselect (dv dir pattern)
  "Return the fd buffer for DV at DIR with search PATTERN."
  (let* ((re (mapcan (lambda (x) `(,(format "--and=%s" x)))
                     (if (stringp pattern) (split-string pattern ",") pattern)))
         (ls-switches (or dired-actual-switches (dv-ls-switches dv)))
         (key (file-name-nondirectory (directory-file-name dir)))
         (query (if (stringp pattern) pattern (mapconcat #'concat pattern ",")))
         (buf (get-buffer-create (concat key "🔍" query "🔍" (dv-id dv))))
         (fd (dirvish-prop :fd-info)) (re (or re (cadr fd)))
         (switches (or (cddr fd) (split-string dirvish-fd-switches))))
    (with-current-buffer buf
      (let (buffer-read-only)
        (erase-buffer)
        (insert "  " dir ":" (make-string (dirvish--subdir-offset) ?\n)))
      (unless (derived-mode-p 'dired-mode)
        (let (dired-buffers) (dired-mode dir ls-switches)))
      (setq-local default-directory dir
                  dired-subdir-alist (list (cons dir (point-min-marker))))
      (dirvish-fd--argparser re switches)
      (dirvish-prop :revert
        (lambda (&rest _)
          (setq dired-subdir-alist (list (car (reverse dired-subdir-alist))))
          (let (buffer-read-only)
            (buffer-disable-undo)
            (delete-region (goto-char (dirvish-prop :content-begin)) (point-max)))
          (buffer-enable-undo)
          (dirvish-fd--start-proc)))
      (let* ((fmt dirvish-fd-header-line-format)
             (l (plist-get fmt :left)) (r (plist-get fmt :right)))
        (dirvish-prop :cus-header (dirvish--mode-line-composer l r t)))
      (dirvish-prop :global-header t)
      (dirvish--setup-dired)
      (dirvish-fd--start-proc) buf)))

;;;###autoload
(defun dirvish-fd (dir pattern)
  "Run `fd' on DIR and go into Dired mode on a buffer of the output.
The command run is essentially:

  fd --color=never `dirvish-fd-switches'
     --and PATTERN [--and PATTERN1 --and PATTERN2 … ]
     --exec-batch `dirvish-fd-ls-program' `dired-listing-switches' --directory

If called with \\`C-u', prompt for the target directory,
`default-directory' is used.  If prefixed with \\`C-u' twice, also
prompt for the search regex PATTERN as a comma separated list."
  (interactive (list (and current-prefix-arg
                          (read-directory-name "Fd target directory: " nil "" t))
                     (and (equal current-prefix-arg '(16))
                          (completing-read-multiple "Pattern: " nil))))
  (let* ((dir (or dir default-directory))
         (buf (dirvish-dired-noselect-a nil dir nil (or pattern "")))
         (dv (with-current-buffer buf (dirvish-curr))))
    (dirvish-save-dedication (switch-to-buffer buf) (dirvish--build-layout dv))))

(define-obsolete-function-alias 'dirvish-fd-ask #'dirvish-fd "Apr 4, 2025")

(provide 'dirvish-fd)
;;; dirvish-fd.el ends here
