ledger / ledger-mode

Emacs Lisp files for interacting with the C++Ledger accounting system
GNU General Public License v2.0
377 stars 75 forks source link

Add user option for completion collection for payees #392

Closed josephmturner closed 5 months ago

josephmturner commented 8 months ago

This user option can be used to offer completion over a list of payees from a file:

(defvar-local my/ledger-payees-collection nil
  ;; TODO: If `project.el' adds support for project-local variables,
  ;; use that instead of `defvar-local'.
  "Memoized payees collection returned by `my/ledger-payees-collection'.")

(defun my/ledger-payees-collection ()
  "Return memoized list of payees contained in `my/payees-file'.
`my/payees-file' can be locally bound to a filepath string to a
file containing a newline-delimited list of payees, where the
filepath is relative to the current project root.  With nil or
nonexistent `my/payees-file', return `ledger-payees-in-buffer.'"
  (let ((payees-file
         (and (bound-and-true-p my/payees-file)
              (expand-file-name my/payees-file (project-root (project-current t))))))
    (cond ((not (and payees-file (file-exists-p payees-file)))
           #'ledger-payees-in-buffer)
          ((or (not my/ledger-payees-collection)
               (file-has-changed-p payees-file
                                   'my/ledger-payees-collection))
           (setf my/ledger-payees-collection
                 (with-current-buffer (find-file-noselect payees-file)
                   (split-string (buffer-string) "\n"))))
          (my/ledger-payees-collection))))

(setopt ledger-payees-collection #'my/ledger-payees-collection)

To use this snippet, create a file containing a list of payees inside your project and set its relative filepath as a dir-local variable:

((ledger-mode . ((my/payees-file . "export/all-payees.txt"))))
josephmturner commented 6 months ago

Ping @purcell :)

purcell commented 6 months ago

I'm not in favour of supporting a customisable method for collecting payee names. Better would be to support Ledger's mechanism for pre-declaring payees with the payee command directive (see section 4.7.1 of the manual), in a similar way to how we support pre-declared accounts (see the ledger-accounts-file variable).

josephmturner commented 5 months ago

Thanks for your feedback @purcell ! Your suggestion to leverage the built-in Ledger functionality makes sense.

I use hledger, and I'm not familiar with this ledger feature. Until this feature is added, here's how I currently apply this patch with el-patch in my config:

;;; -*- lexical-binding: t; -*-
(require 'ledger-mode)
(require 'el-patch)

(defcustom ledger-payees-collection nil
  "Collection of payees to be used for completion.
This option may be set to any kind of collection accepted by
`completing-read', which see."
  :type 'sexp
  :group 'ledger
  :package-version '(ledger-mode . "4.0.0"))

(el-patch-feature ledger-complete-at-point)
(with-eval-after-load 'ledger-complete
  (el-patch-defun ledger-complete-at-point ()
    "Do appropriate completion for the thing at point."
    (let ((end (point))
          start collection
          realign-after
          delete-suffix)
      (cond (;; Date
             (looking-back (concat "^" ledger-incomplete-date-regexp) (line-beginning-position))
             (setq collection (ledger-complete-date (match-string 1) (match-string 2))
                   start (match-beginning 0)
                   delete-suffix (save-match-data
                                   (when (looking-at (rx (one-or-more (or digit (any ?/ ?-)))))
                                     (length (match-string 0))))))
            (;; Effective dates
             (looking-back (concat "^" ledger-iso-date-regexp "=" ledger-incomplete-date-regexp)
                           (line-beginning-position))
             (setq start (line-beginning-position))
             (setq collection (ledger-complete-effective-date
                               (match-string 2) (match-string 3) (match-string 4)
                               (match-string 5) (match-string 6))))
            (;; Payees
             (eq (save-excursion (ledger-thing-at-point)) 'transaction)
             (setq start (save-excursion (backward-word) (point)))
             (setq collection (el-patch-swap
                                #'ledger-payees-in-buffer
                                (or ledger-payees-collection
                                    #'ledger-payees-in-buffer))))
            (;; Accounts
             (save-excursion
               (back-to-indentation)
               (skip-chars-forward "([") ;; for virtual accounts
               (setq start (point)))
             (setq delete-suffix (save-excursion
                                   (when (search-forward-regexp (rx (or eol (or ?\t (repeat 2 space)))) (line-end-position) t)
                                     (- (match-beginning 0) end)))
                   realign-after t
                   collection (if ledger-complete-in-steps
                                  #'ledger-accounts-tree
                                #'ledger-accounts-list))))
      (when collection
        (let ((prefix (buffer-substring-no-properties start end)))
          (list start end
                (if (functionp collection)
                    (completion-table-with-cache
                     (lambda (_)
                       (cl-remove-if (apply-partially 'string= prefix) (funcall collection))))
                  collection)
                :exit-function (lambda (&rest _)
                                 (when delete-suffix
                                   (delete-char delete-suffix))
                                 (when (and realign-after ledger-post-auto-align)
                                   (ledger-post-align-postings (line-beginning-position) (line-end-position))))
                'ignore))))))

(defvar-local my/ledger-payees-collection nil
  ;; TODO: If `project.el' adds support for project-local variables,
  ;; use that instead of `defvar-local'.
  "Memoized payees collection returned by `my/ledger-payees-collection'.")

(defun my/ledger-payees-collection ()
  "Return memoized list of payees contained in `my/payees-file'.
`my/payees-file' can be locally bound to a filepath string to a
file containing a newline-delimited list of payees, where the
filepath is relative to the current project root.  With nil or
nonexistent `my/payees-file', return `ledger-payees-in-buffer.'"
  (let ((payees-file
         (and (bound-and-true-p my/payees-file)
              (expand-file-name my/payees-file (project-root (project-current t))))))
    (cond ((not (and payees-file (file-exists-p payees-file)))
           #'ledger-payees-in-buffer)
          ((or (not my/ledger-payees-collection)
               (file-has-changed-p payees-file
                                   'my/ledger-payees-collection))
           (setf my/ledger-payees-collection
                 (with-current-buffer (find-file-noselect payees-file)
                   (split-string (buffer-string) "\n"))))
          (my/ledger-payees-collection))))

(setopt ledger-payees-collection #'my/ledger-payees-collection)

(provide 'my/ledger-complete)
purcell commented 5 months ago

Check out #411: it should now be enough to set ledger-payees-file to a file that contains lines with payee declarations, e.g. payee Whatever.

josephmturner commented 5 months ago

Thank you @purcell! This solution works for me! Here's how I dynamically create an all-payees.txt file based on a set of journal files whose payees I want to use for completion:

;; Update all-payees.txt from all manual journals
(with-temp-file "export/all-payees.txt"
  (apply #'call-process "hledger" nil t nil "payees" "-I"
         (mapcan (lambda (journal)
                   (list "-f" journal))
                 (directory-files-recursively
                  "import/manual" ".journal$")))
  ;; Prefix each payee with "payee"
  (goto-char (point-min))
  (while (not (eobp))
    (insert "payee ")
    (forward-line)))