hamacs/ha-org-literate.org

454 lines
22 KiB
Org Mode
Raw Normal View History

#+title: Literate Programming with Org
#+author: Howard Abrams
#+date: 2024-07-07
#+filetags: emacs hamacs
#+lastmod: [2024-07-07 Sun]
A literate programming file for literate programming in Emacs Org Files.
#+begin_src emacs-lisp :exports none
;;; ha-org-literate --- literate programming helpers -*- lexical-binding: t; -*-
;;
;; © 2024 Howard Abrams
;; Licensed under a Creative Commons Attribution 4.0 International License.
;; See http://creativecommons.org/licenses/by/4.0/
;;
;; Author: Howard Abrams <http://gitlab.com/howardabrams>
;; Maintainer: Howard Abrams
;; Created: July 7, 2024
;;
;; While obvious, GNU Emacs does not include this file or project.
;;
;;; Commentary:
;;
;; This file contains a collection of functions to easy some of the
;; sharp edges when doing literate programming in Org files.
;;
;; *NB:* Do not edit this file. Instead, edit the original
;; literate file at:
;; /home/howard/other/hamacs/ha-org-literate.org
;; And tangle the file to recreate this one.
;;
;;; Code:
#+end_src
* Introduction
I do a lot of /literate programming/ using capabilities found in the Org project. Over the years, Ive smoothed some of the rough edges by writing supporting functions, collected below.
What are the advantage of [[https://en.wikipedia.org/wiki/Literate_programming][literate programming]]? I listed some in my essay and video about my [[https://howardism.org/Technical/Emacs/literate-devops.html][literate devops]] ideas, but a brief recap:
- ambiguous and/or complicated ideas can be reasoned about in prose before code
- links to ideas, code snippets and supporting projects are more accessible than in comments
- diagrams written in [[file:ha-org.org::*PlantUML][PlantUML]], [[file:ha-org.org::*Graphviz][Graphviz]], or [[file:ha-org.org::*Pikchr][Pikchr]] maintained and displayed with code
- full access to org features, like clocks, task lists and agendas
- mixing languages in the same file, e.g. shell instructions for installing supporting modules before the code that uses that module
If literate programming is so great, why doesnt everyone do it? Most of the /advantages/ listed above are advantages only because we are using Emacs and Org Mode, and getting teams to adopt this requires changing editors and workflows. Therefore, literate programming is a solo endeavor.
Since we are using Emacs, the downsides of using a literate approach (even for personal projects) can be minimized.
*Note:* What follows is advanced LP usage. I would recommend checking out my [[https://www.howardism.org/Technical/Emacs/literate-programming-tutorial.html][Introduction to LP in Org]] before venturing down this essay.
* Navigating Code Blocks
Ive been using Oleh Krehels (abo-abo) [[https://github.com/abo-abo/avy][Avy project]] to jump around the screen for years, and I just learned that I can wrap the =avy-jump= function to provide either/or regular expression and action to perform.
For instance, the following function can be used to quickly select a source code block, and jump to it:
#+begin_src emacs-lisp
(defun avy-jump-org-block ()
"Jump to org block using Avy subsystem."
(interactive)
(avy-jump (rx "#+begin_src ") :action 'goto-char))
#+end_src
I need to take advantage of this feature more.
* Evaluating Code
Hitting ~C-c C-c~ in a source code block /evaluates/ the code. Simple, sure, but the following enhancements make this more accessible.
** Syncing Tangled Code Automatically
Any file can set [[https://emacsdocs.org/docs/emacs/File-Variables][file-local Emacs variables]] when a file is loaded, but we can also run Emacs functions with the =eval:= sequence. If you place the following code at the bottom of your Org file, saving the buffer automatically tangles it:
#+begin_src org
# Local Variables:
# eval: (add-hook 'after-save-hook #'org-babel-tangle t t)
# End:
#+end_src
If you set the =comments= header argument to =link=, you can actually make direct changes to your tangled code to have them update your original literate org file. For instance, add:
#+begin_src org
#+PROPERTY: header-args: :tangle yes :comments link
#+end_src
See also the [[https://gitlab.com/mtekman/org-tanglesync.el][org-tanglesync]] project for putting a bit of controls around this feature.
Another idea is to call =org-babel-execute-buffer= when a file is loaded to automatically evaluate all blocks:
#+begin_src org
# eval: (org-babel-execute-buffer)
#+end_src
Personally, I find that I would like to call this function manually instead of automatically.
** Evaluating a Block
At times I would like to jump to a particular block, evaluate the code, and jump back. This seems like a great job for the [[https://github.com/abo-abo/avy][avy project]]. The =avy-jump= function takes a regular expression of text /in the frame/ (which means you can specify text in other windows), and highlights each match. Normally, selecting a match moves the cursor to that match, the =avy-jump= accepts a function to execute instead:
#+begin_src emacs-lisp
(defun org-babel-execute-src-block-at-point (&optional point)
"Call `org-babel-execute-src-block' at POINT."
(save-excursion
(goto-char point)
(org-babel-execute-src-block)))
(defun avy-org-babel-execute-src-block ()
"Call `org-babel-execute-src-block' on block given by Avy.
Use Avy subsystem to select a visible Org source code block,
e.g. `#+begin_src', and then executes the code without moving
the point."
(interactive)
(avy-jump (rx "#+begin_src ")
:action
'org-babel-execute-src-block-at-point))
#+end_src
** Evaluating a Section
A trick to =org-babel-tangle=, is that it tangles /what is available/, that is, it will only tangle code blocks that are visible after narrowing to the current org section.
#+begin_src emacs-lisp :results silent
(defun org-babel-execute-subtree ()
"Execute all Org source blocks in current subtree."
(interactive "P")
(save-excursion
(org-narrow-to-subtree)
(org-babel-execute-buffer)
(widen)))
#+end_src
** Editing a Block
#+begin_src emacs-lisp
(defun org-babel-edit-src-block-at-point (&optional point)
"Call `org-babel-execute-src-block' at POINT."
(save-excursion
(goto-char point)
(org-edit-src-code)))
(defun avy-org-babel-edit-src-block ()
"Call `org-edit-src-code' on block given by Avy.
Use Avy subsystem to select a visible Org source code block,
e.g. `#+begin_src', and then executes the code without moving
the point."
(interactive)
(avy-jump (rx "#+begin_src ")
:action
'org-babel-edit-src-block-at-point))
#+end_src
* Finding Code
One of the issues with literate programming is using the same interface for moving around code when the source code is in org files.
** Searching by Function Name
I wrote a function, =ha-org-code-block-jump= to use the standard =xref= interface to jump to a function definition /in the literate org file/. Since the code is specific to /Emacs Lisp/ (the bulk of my literate programming code is in Lisp), Im leaving it in my [[file:ha-programming-elisp.org::*Goto Definitions][programming-elisp]] configuration.
TODO: Do all the =xref-= functions for search an collection of org files, not just definition.
** Searching by Header
As large literate programming projects grow, I refine, re-organize and refactor content. I dont always remember where I put particular code. For instance, in my Emacs configuration, did I configure /eww/, in [[file:ha-config.org][my default config]] file, or did I move it somewhere? Originally, after loading the file, I could issue a call to [[file:ha-general.org::*Consult][consult-imenu]] to get to the right location, but that assumes I have the correct file loaded.
The following section shows some code I wrote one evening, to use the fuzzy matching features of [[file:ha-config.org::*Orderless][Orderless]], to choose a headline in any of my Org files in a project, and then load that file and jump to that headline. The interface is =ha-hamacs-edit-file-heading=, and the supporting functions begin with =ha-hamacs-edit-=:
#+begin_src emacs-lisp
(defun ha-hamacs-edit-file-heading (&optional project-root)
"Edit a file based on a particular heading.
After presenting list of headings from all Org files,
it loads the file, and jumps to the line number where
the heading is located."
(interactive)
(let* ((default-directory (or project-root (project-root (project-current))))
(file-headings (ha-hamacs-edit--file-heading-list project-root))
(file-choice (completing-read "Edit Heading: " file-headings))
(file-tuple (alist-get file-choice file-headings
nil nil 'string-equal)))
(find-file (first file-tuple))
(goto-line (second file-tuple))))
#+end_src
This function collects all possible headers by issuing a call to =ripgrep=, which returns something like:
#+begin_example
ha-applications.org:29:* Git and Magit
ha-applications.org:85:** Git Gutter
ha-applications.org:110:** Git Delta
ha-applications.org:136:** Git with Difftastic
...
"ha-applications.org:385:* Web Browsing
ha-applications.org:386:** EWW
...
#+end_example
We then filter out non-useful headers (with =ha-hamcs-edit—filter-heading=), and convert the headlines with =ha-hamcs-edit—process-entry= to be more presentable:
#+begin_src emacs-lisp
(defun ha-hamacs-edit--file-heading-list (&optional project-root)
"Return list of lists of headlines and file locations.
Using the output from the shell command, `ha-hamacs-edit-ripgrep-headers',
it parses and returns something like:
'((\"Applications∷ Git and Magit\" \"ha-applications.org\" 29)
(\"Applications∷ Git and Magit ﹥ Git Gutter\" \"ha-applications.org\" 85)
(\"Applications∷ Git and Magit ﹥ Git Delta\" \"ha-applications.org\" 110)
(\"Applications∷ Git and Magit ﹥ Time Machine\" \"ha-applications.org\" 265)
...)"
(let ((default-directory (or project-root (project-root (project-current)))))
(thread-last ha-hamacs-edit-ripgrep-headers
(shell-command-to-list)
;; Let's remove non-helpful, duplicate headings,
;; like Introduction:
(seq-remove 'ha-hamacs-edit--filter-heading)
;; Convert the results into both a displayable
;; string as well as the file and line structure:
(seq-map 'ha-hamacs-edit--process-entry))))
#+end_src
As the above functions documentation string claims, I create a list that contains the data structure necessary for =completing-read= as well as the information I need to load/jump to a position in the file. This is a three-element list of the /headline/, /filename/ and /line number/ for each entry:
#+begin_src emacs-lisp :tangle no
'(("Applications∷ Git and Magit" "ha-applications.org" 29)
("Applications∷ Git and Magit ﹥ Git Gutter" "ha-applications.org" 85)
("Applications∷ Git and Magit ﹥ Git Delta" "ha-applications.org" 110)
("Applications∷ Git and Magit ﹥ Time Machine" "ha-applications.org" 265)
("Applications∷ Git and Magit ﹥ Gist" "ha-applications.org" 272)
("Applications∷ Git and Magit ﹥ Forge" "ha-applications.org" 296)
("Applications∷ Git and Magit ﹥ Pushing is Bad" "ha-applications.org" 334)
("Applications∷ Git and Magit ﹥ Github Search?" "ha-applications.org" 347)
("Applications∷ ediff" "ha-applications.org" 360)
("Applications∷ Web Browsing" "ha-applications.org" 385)
("Applications∷ Web Browsing ﹥ EWW" "ha-applications.org" 386)
;; ...
)
#+end_src
Well use this shell command to call =ripgrep= to search my collection of org files:
#+begin_src emacs-lisp
(defvar ha-hamacs-edit-ripgrep-headers
(concat "rg"
" --no-heading"
" --line-number"
;; " --max-depth 1"
" -e '^\\*+ '"
" *.org")
"A ripgrep shell call to search my headers.")
#+end_src
Not every header should be a destination, as many of my org files have duplicate headlines, like *Introduction* and *Technical Artifacts*, so I can create a regular expression to remove or flush entries:
#+begin_src emacs-lisp
(defvar ha-hamacs-edit-flush-headers
(rx "*" (one-or-more space)
(or "Introduction"
"Install"
"Overview"
"Summary"
"Technical Artifacts"))
"Regular expression matching headers to purge.")
#+end_src
And this next function is callable by the filter function, it uses the regular expression and returns true (well, non-nil) if the line entry given, =rg-input=, should be removed:
#+begin_src emacs-lisp
(defun ha-hamacs-edit--filter-heading (rg-input)
"Return non-nil if we should remove RG-INPUT.
These are headings with typical, non-unique entries,
like Introduction and Summary."
(string-match ha-hamacs-edit-flush-headers rg-input))
#+end_src
The =seq-map= needs to take each line from the =ripgrep= call and convert it to a list that I can use for the =completing-read= prompt. I love the combination of =seq-let= and =s-match= from Magnars [[https://github.com/magnars/s.el][String library]]. The built-in function, =string-match= returns the index in the string where the match occurs, and this is useful for positioning a prompt, in this case, I want the /contents/ of the matches, and =s-match= returns each /grouping/.
#+begin_src emacs-lisp
(defun ha-hamacs-edit--process-entry (rg-input)
"Return list of heading, file and line number.
Parses the line entry, RG-INPUT, from a call to `rg',
using the regular expression, `ha-hamacs-edit-rx-ripgrep'.
Returns something like:
(\"Some Heading\" \"some-file.org\" 42)"
(seq-let (_ file line level head)
(s-match ha-hamacs-edit-rx-ripgrep rg-input)
(list (ha-hamacs-edit--new-heading file head (length level))
file
(string-to-number line))))
#+end_src
Before we dive into the implementation of this function, lets write a test to validate (and explain) what we expect to return:
#+begin_src emacs-lisp :tangle no
(ert-deftest ha-hamacs-edit--process-entry-test ()
(setq ha-hamacs-edit-prev-head-list '())
(should (equal
(ha-hamacs-edit--process-entry
"ha-somefile.org:42:* A Nice Headline :ignored:")
'("Somefile∷ A Nice Headline " "ha-somefile.org" 42)))
;; For second-level headlines, we need to keep track of its parent,
;; and for this, we use a global variable, which we can set for the
;; purposes of this test:
(setq ha-hamacs-edit-prev-head-list '("Parent"))
(should (equal
(ha-hamacs-edit--process-entry
"ha-somefile.org:73:** Another Headline")
'("Somefile∷ Parent﹥ Another Headline"
"ha-somefile.org" 73)))
(setq ha-hamacs-edit-prev-head-list '("Parent" "Subparent"))
(should (equal
(ha-hamacs-edit--process-entry
"ha-somefile.org:73:*** Deep Heading")
'("Somefile∷ Parent﹥ Subparent﹥ Deep Heading"
"ha-somefile.org" 73)))
(setq ha-hamacs-edit-prev-head-list '("Parent" "Subparent"
"Subby" "Deepsubby"))
(should (equal
(ha-hamacs-edit--process-entry
"ha-somefile.org:73:***** Deepest Heading")
'("Somefile∷ ... Deepest Heading"
"ha-somefile.org" 73))))
#+end_src
We next need a regular expression to pass to =s-match= to parse the output:
#+begin_src emacs-lisp
(defvar ha-hamacs-edit-rx-ripgrep
(rx (group (one-or-more (not ":"))) ":" ; filename
(group (one-or-more digit)) ":" ; line number
(group (one-or-more "*")) ; header asterisks
(one-or-more space)
(group (one-or-more (not ":")))) ; headline without tags
"Regular expression of ripgrep default output with groups.")
#+end_src
The =—new-heading= function will /prepend/ the name of the file and its parent headlines (if any) to the headline to be more useful in both understanding the relative context of the headline, as well as better to search using fuzzy matching.
This /context/ is especially important as =completing-read= will place the most recent choices at the top.
I found the use of =setf= to be quite helpful in manipulating the list of parents. Remember a =list= in a Lisp, is a /linked list/, and we can easily replace one or more parts, by pointing to an new list. This is my first iteration of this function, and I might come back and simplify it.
Essentially, if we get to a top-level headline, we set the list of parents to a list containing that new headline. If we get a second-level headine, =B=, and our parent list is =A=, we create a list =(A B)= by setting the =cdr= of =(A)= to the list =(B)=. The advantage of this approach is that if the parent list is =(A C D)=, the =setf= works the same, and the dangled /sublist/, =(C D)= gets garbage collected.
#+begin_src emacs-lisp
(defun ha-hamacs-edit--new-heading (file head level)
"Return readable entry from FILE and org headline, HEAD.
The HEAD headline is, when LEVEL is greater than 1,
to include parent headlines. This is done by storing
the list of parents in `ha-hamacs-edit-prev-head-list'."
;; Reset the parent list to include the new HEAD:
(cond
((= level 1)
(setq ha-hamacs-edit-prev-head-list (list head)))
((= level 2)
(setf (cdr ha-hamacs-edit-prev-head-list) (list head)))
((= level 3)
(setf (cddr ha-hamacs-edit-prev-head-list) (list head)))
((= level 4)
(setf (cdddr ha-hamacs-edit-prev-head-list) (list head)))
((= level 5)
(setf (cddddr ha-hamacs-edit-prev-head-list) (list head))))
;; Let's never go any deeper than this...
(format "%s∷ %s"
(ha-hamacs-edit--file-title file)
(s-join "﹥ " ha-hamacs-edit-prev-head-list)))
#+end_src
The following test should pass some mustard and explain how this function works:
#+begin_src emacs-lisp :tangle no
(ert-deftest ha-hamacs-edit--new-heading-test ()
(should (equal
(ha-hamacs-edit--new-heading "ha-foobar.org" "Apples" 1)
"Foobar∷ Apples"))
(setq ha-hamacs-edit-prev-head-list '("Apples"))
(should (equal
(ha-hamacs-edit--new-heading "ha-foobar.org" "Oranges" 2)
"Foobar∷ Apples﹥ Oranges"))
(setq ha-hamacs-edit-prev-head-list '("Apples" "Oranges"))
(should (equal
(ha-hamacs-edit--new-heading "ha-foobar.org" "Bananas" 3)
"Foobar∷ Apples﹥ Oranges﹥ Bananas"))
(setq ha-hamacs-edit-prev-head-list '("Apples" "Oranges" "Bananas"))
(should (equal
(ha-hamacs-edit--new-heading "ha-foobar.org" "Cantaloupe" 4)
"Foobar∷ Apples﹥ Oranges﹥ Bananas﹥ Cantaloupe")))
#+end_src
I store the current list of parents, in the following (/gasp/) /global variable/:
#+begin_src emacs-lisp
(defvar ha-hamacs-edit-prev-head-list '("" "")
"The current parents of headlines as a list.")
#+end_src
I would like to make the /filename/ more readable, I use the =s-match= again, to get the groups of a regular expression, remove all the dashes, and use =s-titleize= to capitalize each word:
#+begin_src emacs-lisp
(defun ha-hamacs-edit--file-title (file)
"Return a more readable string from FILE."
(s-with file
(s-match ha-hamacs-edit-file-to-title)
(second)
(s-replace "-" " ")
(s-titleize)))
(defvar ha-hamacs-edit-file-to-title
(rx (optional (or "README-" "ha-"))
(group (one-or-more any)) ".org")
"Regular expression for extracting the interesting part of a
file to use as a title.")
#+end_src
So the following tests should pass:
#+begin_src emacs-lisp :tangle no
(ert-deftest ha-hamacs-edit-file-title-test ()
(should (equal (ha-hamacs-edit-file-title "ha-apples.org") "Apples"))
(should (equal (ha-hamacs-edit-file-title "apples.org") "Apples"))
(should (equal (ha-hamacs-edit-file-title "README-apples.org") "Apples"))
(should (equal (ha-hamacs-edit-file-title "README.org") "Readme")))
#+end_src
* Keybindings
With a lovely collection of functions, we need to have a way to easily call them. Ive been using the =pretty-hydra= feature of major-mode-hydra:
#+begin_src emacs-lisp
(pretty-hydra-define org-babel (:color blue)
("Navigate"
(("g" avy-jump-org-block "goto")
("j" org-next-block "previous block" :color pink)
("k" org-previous-block "next block" :color pink))
"Evaluate"
(("o" avy-org-babel-execute-src-block "block")
("h" org-babel-execute-subtree "section")
("b" org-babel-execute-buffer "buffer"))
"Tangle"
(("t" org-babel-tangle "to File")
("T" org-babel-detangle "from File"))
"Misc"
(("e" avy-org-babel-edit-src-block "edit"))))
#+end_src
And tie this hydra into the existing leader system:
#+begin_src emacs-lisp
(ha-leader :keymaps 'org-mode-map "o s" '("babel" . org-babel/body))
#+end_src
* Technical Artifacts :noexport:
Let's =provide= a name so we can =require= this file:
#+begin_src emacs-lisp :exports none
(provide 'ha-org-literate)
;;; ha-org-literate.el ends here
#+end_src
#+DESCRIPTION: literate programming in Emacs Org Files.
#+PROPERTY: header-args:sh :tangle no
#+PROPERTY: header-args:emacs-lisp :tangle yes
#+PROPERTY: header-args :results none :eval no-export :comments no mkdirp yes
#+OPTIONS: num:nil toc:nil todo:nil tasks:nil tags:nil date:nil
#+OPTIONS: skip:nil author:nil email:nil creator:nil timestamp:nil
#+INFOJS_OPT: view:nil toc:nil ltoc:t mouse:underline buttons:0 path:http://orgmode.org/org-info.js