UP | HOME

Literate Configuration Management with vanilla Emacs

Table of Contents

Update: <2022-12-23 Fri>:

This is all completely unnecessary. It turns out that you can practically achieve the same functionality with imenu-list, without it being so slow.

Introduction

I manage my dotfiles using GNU Stow. This means that I have a git directory dotfiles, which contains the configuration files for the applications I have bothered tailoring to my needs. Maintaining it is not as simple as it sounds, though.

In this article I aim to describe how this got just a little bit easier for me in the past couple days

Building the script

The following folder hierarchy shows part of the general dotfiles problem:

  • dotfiles
    • bspwm
      • .config
        • bspwm
          • bspwm.org
          • bspwmrc
    • sxhkd
      • .config
        • sxhkd
          • sxhkd.org
          • sxhkdrc
    • zsh
      • zsh.org
      • .zshrc
    • emacs
      • .emacs.d
        • config.org

The directories above highlight the fact that there is not a single place to look. Almost every package has something different in the way it structures its dotfiles, that makes the use of regular expressions necessary. Obviously, we want to recursively search our directory for .org files.

(setq chatziiola/dot-dir "~/dotfiles/emacs")
(directory-files-recursively chatziiola/dot-dir "org$")

Dealing with too many org files

A problem arises: Too many files within the emacs directory, almost all of which from the straight subdirectory (this is what hapenned to me, it may be different for you). To solve that we need to filter out non-important to our configs org files.

The way I see it there are two possible scenarios, neither of which do I like:

  1. Play with the output of directory-files-recursively, filtering out whatever contains an element of our blacklist
  2. Redefine the directory-files-recursively function with one more parameter DIR-REGEXP. Since it is a rather small function, I’m pretty sure we can work it out.

Alright, alright, alright. I found the third option:

  1. Instead of using files in this format <app>.org , we are to use files only named as config.org and then differentiate them in the selection panel using their “TITLE” property

    (setq chatziiola/dot-dir "~/dotfiles/")
    (directory-files-recursively chatziiola/dot-dir "^config.org$")
    

Getting the titles

Make sure that we get the title of our configuration files:

(setq chatziiola/dot-dir "~/dotfiles/")
(dolist (file (directory-files-recursively chatziiola/dot-dir "^config.org$"))
        (unless (stringp (chatziiola/get-keyword-value "TITLE" file))
                 (print file)))
  • Complementary functions

    These functions were found online (albeit the second one has been changed) when I was building my lecture notetaking scripts

    ; This and the next func ( even though slightly modified ):
    ; src: https://stackoverflow.com/questions/66574715/how-to-get-org-mode-file-title-and-other-file-level-properties-from-an-arbitra
    (defun ndk/get-keyword-key-value (kwd)
      "This function was found on stackoverflow - google or see config.org for more. \n It should only be used by ~chatziiola/get-keyword-value~"
      (let ((data (cadr kwd)))
        (list (plist-get data :key)
              (plist-get data :value))))
    
    (defun chatziiola/get-keyword-value (key &optional file)
      "Return the actual value associated with key 'myString' of the current org buffer\n For example: (chatziiola/get-keyword-value \"TITLE\") should return the title of that org file."
            ; Return the nth element of the list
      ;; TODO better documentation
      (let ((file (or file "")))
        (cond
         ((not (string-blank-p file))
          (with-current-buffer (find-file-noselect file) (chatziiola/get-keyword-value key)))
         ;; In that case key is a list of keys actually
         ((proper-list-p key)
          (let ((keyVals '()))
            (cl-loop for title in key
                  do (add-to-list 'keyVals (chatziiola/get-keyword-value title) t))
            keyVals)
          )
         (t
          (nth 1 (assoc key (org-element-map (org-element-parse-buffer 'greater-element) '(keyword) #'ndk/get-keyword-key-value)))))))
    

Presenting Them To The User

Create a dictionary in the form of "TITLE":"FILE":

We do not call it a directory but rather a prompt-list

(setq chatziiola/dot-dir "~/dotfiles/")

(let* ((config-files (directory-files-recursively chatziiola/dot-dir "^config.org$"))
       (prompt-list (seq-map (lambda (e) (list (format "%-20s %-25s" (chatziiola/get-keyword-value "TITLE" e) e) e)) config-files)))
prompt-list)

Prompt the user to select and get the file to be edited

(setq chatziiola/dot-dir "~/dotfiles/")

(let* ((config-files (directory-files-recursively chatziiola/dot-dir "^config.org$"))
       (prompt-list (seq-map (lambda (e) (list (format "%-20s %-25s" (chatziiola/get-keyword-value "TITLE" e) e) e)) config-files))
       (prompt-answer (completing-read "Select configuration file: " prompt-list)))
; Prompt an
(car (last (assoc prompt-answer prompt-list))))

Script building is over: Just open the file

(setq chatziiola/dot-dir "~/dotfiles/")

(find-file
       (let* ((config-files (directory-files-recursively chatziiola/dot-dir "^config.org$"))
              (prompt-list (seq-map (lambda (e) (list (format "%-20s %-25s" (chatziiola/get-keyword-value "TITLE" e) e) e)) config-files))
              (prompt-answer (completing-read "Select configuration file: " prompt-list)))
       ; Prompt an
       (car (last (assoc prompt-answer prompt-list)))))

Integrating rougier’s scripts

With the steps above with have achieved a basic level of ease when dealing with dotfiles. What is missing?

  1. Declaring it as an interactive function
  2. Binding it to a key
  3. Integrating Rougier

@rougier functions

rougier implemented these wonderful functions ( that inspired me to work on this ):

(defun my/config ()
  "Create a new for editing configuration"

  (interactive)
  (select-frame (make-frame '((name . "my/config")
                              (width . 150)
                              (height . 45))))
  (find-file "~/.emacs.d/config.org")
  (my/org-sidebar-toggle))
(require 'imenu)
(require 'imenu-list)

(defun my/org-tree-to-indirect-buffer ()
  "Create indirect buffer, narrow it to current subtree and unfold blocks"

  (org-tree-to-indirect-buffer)
  (org-show-block-all)
  (setq-local my/org-blocks-hidden nil))

(defun my/org-sidebar ()
  "Open an imenu list on the left that allow navigation."

  (interactive)
  (setq imenu-list-after-jump-hook #'my/org-tree-to-indirect-buffer
        imenu-list-position 'left
        imenu-list-size 36
        imenu-list-focus-after-activation t)

  (let ((heading (substring-no-properties (or (org-get-heading t t t t) ""))))
    (when (buffer-base-buffer)
      (switch-to-buffer (buffer-base-buffer)))
    (imenu-list-minor-mode)
    (imenu-list-stop-timer)
    (hl-line-mode)
    (face-remap-add-relative 'hl-line :inherit 'nano-subtle)
    (setq header-line-format
          '(:eval
            (nano-modeline-render nil
                                  (buffer-name imenu-list--displayed-buffer)
                                  "(outline)"
                                  "")))
    (setq-local cursor-type nil)
    (when (> (length heading) 0)
      (goto-char (point-min))
      (search-forward heading)
      (imenu-list-display-dwim))))

(defun my/org-sidebar-toggle ()
  "Toggle the org-sidebar"

  (interactive)
  (if (get-buffer-window "*Ilist*")
      (progn
        (quit-window nil (get-buffer-window "*Ilist*"))
        (switch-to-buffer (buffer-base-buffer)))
    (my/org-sidebar)))

One does not really need to understand all of them. They just have to look at the first one. The reason why this is the path of least effort is obvious. By changing it just a little bit, we have the perfect end product:

Merging them with our funcs

(setq chatziiola/dot-dir "~/dotfiles/")


(defun chatziiola/open-conf ()
     "Finds all ~config.org~ files within chatziiola/dot-dir and opens them up for you"

  (interactive)
  (let* ((config-files (directory-files-recursively chatziiola/dot-dir "^config.org$"))
         (prompt-list (seq-map (lambda (e) (list (format "%-20s %-25s" (chatziiola/get-keyword-value "TITLE" e) e) e)) config-files))
         (prompt-answer (completing-read "Select configuration file: " prompt-list))
         (cur-conf-file (car (last (assoc prompt-answer prompt-list)))))
      (select-frame (make-frame '((name . "my/config")
                                  (width . 150)
                                  (height . 45))))
      (find-file cur-conf-file))
  (my/org-sidebar-toggle)
)

Making it usable

Autoload the configuration function ( so that it is always available ), stolen from rougier, once again.

(autoload 'chatziiola/open-conf
  (expand-file-name "init.el" user-emacs-directory)
  "Autoloaded my/config command."
  t)

;; For those of you that don't use evil, you better start :P
(evil-define-key 'normal 'global (kbd "<leader>fp") 'chatziiola/open-conf)

Summing Up ( some extra details )

This article took for granted that one has some prior experience with org mode, though I understand that this is not always the case. To gain advantage of the scripts outlined above you only need to add the following lines at the beginning of your config.org files:

#+TITLE: <yourtitle>
#+PROPERTY: header-args :tangle <path-to-your-file>

And enclose your code in source blocks such as these:

Lastly: Do not forget to add config.org to .stow-local-ignore to avoid clutter

Originally created on 2022-09-03 Sat 23:56