hamacs/ha-code-notes.org
Howard Abrams 968885e5f3 Tidying my Bookmarks, Annotations and Code Notes
Pretty pleased with all the way I can explore a new code base.
2026-07-15 21:59:57 -07:00

14 KiB
Raw Blame History

Taking Notes with Org

A literate programming file for configuring Emacs to take notes.

Introduction

Seems silly to have a full set of instructions for taking notes using Org-mode, because that is what it does, but this is a bit special.

When I capture notes with a link to some source code, the destination is a clocked in task. Useful, but seems to require a dedicated task and some forethought. The following project pairs a “source code file” (which can be any file, actually) and a “notes file” with headers based on the function in the original file.

  digraph G {
    rankdir=LR;
    // Make to icons side-by-side
    code_file [shape=note, label="", fixedsize=true, width=1, height=1.5];
    note_file [shape=note, label="", fixedsize=true, width=1, height=1.5];

    // Create a couple of label to live _below_ the icons:
    code_label [shape=plaintext, label="foobar.py"];
    note_label [shape=plaintext, label=".foobar-note.org"];
    { rank=same; code_file; code_label; }
    { rank=same; note_file; note_label; }
    code_file -> code_label [style=invis];
    note_file -> note_label [style=invis];

    // Connect the code and note icons:
    code_file -> note_file;
  }

/git/howard/hamacs/media/commit/968885e5f348e927eded89a491fc788cf7ab4152/ha-code-notes-illustration.png

This allows me to wax poetic with a parallel, but separate org file.

Helper Functions

While this project started simple, Ive expanded the ideas, and this requires some custom helper functions. For instance, I want to add a buffer-level property to an org-mode file, for instance:

#+TITLE: The title of the file
#+PROPERTY: key1 value1

The tricky bit about this function is that if (in the above example) key1 exists, we should replace it, not add to it.

  (defun ha-org-set-buffer-property (key value)
    "Set a buffer-level property at the top of an Org file.
  If KEY already refers to a #+PROPERTY, replace it.
  Otherwise, insert it at the end of the Org header lines."
    (let ((property-line (format "#+PROPERTY: %s %s" key value))
          (magic-re (rx  (group line-start "#+PROPERTY:"
                                    space (literal key) space
                                    (zero-or-more any)
                                    line-end))))
      (defun process-line ()
        "Helper function returns non-nil if more to process.
      This works because `replace-match' and `insert' return nil,
      while `forward-line' returns a non-nil value."
        (cond
         ((looking-at magic-re) (replace-match property-line))
         ((looking-at "#")      (forward-line))
         (t                     (insert property-line ?\n))))

      (save-excursion
        (goto-char (point-min))
        (while (process-line)))))

And we need the ability to read it. The Org API doesnt have a function to read it without parsing the entire structure, so we use the org-collect-keywords for the PROPERTY value, and then parse the results.

  (defun ha-org-get-buffer-property (key)
    "Get the value of a buffer-level #+PROPERTY matching KEY."
    (let* ((properties (thread-last "PROPERTY"
                                    (list)   ; Requires a list of keywords
                                    (org-collect-keywords)
                                    (car)))  ; A list of lists? Get first entry
           ;; Each entry in properties is a string with the key and value:
           (entry (seq-find (lambda (k) (s-starts-with? key k)) properties)))
      (substring entry (1+ (s-index-of " " entry)))))

They work like:

  (ha-org-set-buffer-property "foor" "bar")
  (ha-org-get-buffer-property "foo")  ; ⟹ "bar"

IMenu has a mode-agnostic approach to jumping to sections. This could work whether the file is an formatted in org-mode, markdown, or even source code. This function allows me to jump to a particular header in either Org, Markdown, or other files that define the defun interface differently.

  (defun imenu-goto (header)
    "Jump to the first `imenu' entry whose name contains HEADER.
  Like `consult-imenu', this matches HEADER as a case-insensitive
  substring rather than requiring an exact name, and jumps straight
  to the first hit instead of prompting.

  Note that this won't work if IMenu is stale and requires refreshing."
    ;; Overshadowing imenu's lookup function seems overly sneaky!
    (let ((imenu-name-lookup-function
           (lambda (str name)
             (let ((case-fold-search t))
               (string-match-p (regexp-quote str) name)))))
      ;; Calling `imenu' programmatically is a pain!
      (when-let ((item (imenu--in-alist header (imenu--make-index-alist))))
        (imenu item))))

Code Notes

This function defines what the notes filename should look like, loads it in a side window, and adds a back reference in the form of an org-mode property. Next it needs to either find the section that matches the function in the code we are writing about, or jumps to the bottom and creates it.

  (defun ha-code-notes ()
    "Open an Org file based on the current opened file.
  The pattern for choosing the name of the org file is:

     foobar.py --> .foobar-notes.org

  The Org header will be the name of the function in the original source
  code file. This means you have one note section per function, which
  should be fine in practice because functions are small and succinct,
  right?"
    (interactive)
    (let* ((orig-file   (buffer-file-name))
           (line-num    (line-number-at-pos))
           (orig-parent (file-name-directory orig-file))
           (orig-base   (file-name-base orig-file))

           ;; Keep in mind the `orig-parent' has a final slash, so the
           ;; initial . here marks it as hidden:
           (note-file   (format "%s.%s-notes.org"
                                orig-parent orig-base))
           (header      (which-function)))

      ;; With the above local variables defined, we can open the file in
      ;; a window (pane) to the side:
      (find-file-other-window note-file)
      (ha-org-set-buffer-property "XREF" orig-file)

      (goto-char (point-min))  ; jump to start of file

      ;; The `condition-case' is Elisp's way of a try..catch:
      (condition-case nil
          ;; Find the first Org header that matches `header':
          (re-search-forward (rx line-start
                                 (one-or-more "*")
                                 (one-or-more space)
                                 (optional (or "=" "~"))
                                 (literal header)
                                 (optional (or "=" "~"))))
        (error
         ;; We didn't find a header matching the function, so we jump to
         ;; the end of the file and create a new heading:
         (goto-char (point-max))
         (org-insert-heading)
         (insert (format "%s" header))
         (org-insert-property-drawer)
         (org-set-property "XREF_LINE" (number-to-string line-num))
         ;; In case we want to change the section header name, we store
         ;; the name of the function that led us here as a property:
         (org-set-property "XREF_NAME" header)

         (when-let ((buf (find-buffer-visiting orig-file)))
           (with-current-buffer buf
             (ha-code--fringe-notes)))))

      ;; We are somewhere in the file, so go to the end of the block:
      (org-end-of-subtree)

      ;; If the subtree ends mid-line, insert a newline:
      (unless (eq (line-beginning-position) (point))
        (end-of-line)
        (insert "\n"))))

Use the builtin autoinsert feature to inject a basic template at the beginning of the notes file when we first create the notes file:

    (use-package autoinsert
      :config
      (define-auto-insert
        (cons (rx "/." (one-or-more (not "/")) "-notes.org" string-end) "Org Notes Template")
         '("Short description: "
           "#+TITLE: "
           (s-titleized-words (s-replace-regexp (rx (any "-" "_")) " "
                                               (file-name-base (buffer-file-name))))
           \n
           "#+DATE:"   (format-time-string "%Y-%m-%d %a")
           \n
           "#+LASTMOD:" (format-time-string "[%Y-%m-%d %a]")
           \n \n)))

If we are inside one of these note files, lets have a quick way to return back to the original “code” file:

  (defun ha-code-notes-return ()
    "Return to the code referenced in the notes.
  Essentially pretends we have a backlink without a database."
    (interactive)
    (let* ((filename (ha-org-get-buffer-property "XREF"))
           (line-num (car (org-property-values "XREF_LINE")))
           (function (or (car (org-property-values "XREF_NAME")) (which-function))))

      ;; If the property is set, load that file (which jumps to the
      ;; buffer if it is displayed), otherwise, we assume the previous
      ;; buffer contains it:
      (if filename
          (find-file-other-window filename)
        (switch-to-prev-buffer))
      (if line-num
          (goto-line (string-to-number line-num)))

      ;; If the line number got out of sync so that the point is no
      ;; longer in the correct function, use `find-function' to
      ;; reposition the point:
      (unless (equal (which-function) function)
        (if (derived-mode-p 'prog-mode)
            (xref-find-definitions function)
          (imenu-goto function)))))

And give us keybinding that will either go to the notes (if we are in some code) or return to the source code (if we are in our notes):

  (defun ha-code-notes-dwim ()
    "Open the notes buffer, or return to the code."
    (interactive)
    (when (buffer-file-name)
      (if (string-match (rx "/." ; A hidden file
                            (one-or-more (not "/"))
                            "-notes.org" string-end)
                        (buffer-file-name))
          (ha-code-notes-return)
        (ha-code-notes))))

  (ha-leader "n c" '("code notes" . ha-code-notes-dwim))

Fringe Indicators for Notes

A code file with an associated notes file is easy to forget about. Let's mark, in the fringe, every line that has a note, so we notice it as we scroll past:

  (define-fringe-bitmap 'ha-code-notes-bitmap
    [#b00001100
     #b00010110
     #b00010111
     #b00101110
     #b00101110
     #b01011100
     #b01011100
     #b10110000
     #b10010000
     #b11100000]
    nil nil 'center)

  (defface ha-code-notes-face
    '((t :foreground "yellow"))
    "Face for the fringe marker indicating a line has an associated note.")

  (defvar-local ha-code-notes-fringe-overlays nil
    "Overlays marking lines in this buffer that have notes in the paired notes file.")

  (defun ha-code-notes--fringe ()
    "Mark, in the fringe, every line in this buffer that has a note.
  Notes live in the paired `.BASE-notes.org' file (see
  `ha-code-notes') as headings whose XREF/XREF_LINE
  properties point back to a line in this file."
    (mapc #'delete-overlay ha-code-notes--fringe-overlays)
    (setq ha-code--notes--fringe-overlays nil)
    (let* ((orig-file (buffer-file-name))
           (note-file (and orig-file
                           (format "%s.%s-notes.org"
                                   (file-name-directory orig-file)
                                   (file-name-base orig-file))))
           lines)
      (when (and note-file (file-exists-p note-file))
        (with-temp-buffer
          (insert-file-contents note-file)
          (org-mode)
          (org-map-entries
           (lambda ()
             (when (equal (ha-org-get-buffer-property "XREF") orig-file)
               (push (string-to-number (org-entry-get nil "XREF_LINE")) lines)))))
        (dolist (line lines)
          (save-excursion
            (goto-char (point-min))
            (forward-line (1- line))
            (let ((ov (make-overlay (point) (point))))
              (overlay-put ov 'before-string
                           (propertize "x" 'display
                                       '(left-fringe ha-code-notes-bitmap
                                                     ha-code-notes-face)))
              (push ov ha-code-notes--fringe-overlays)))))))

  (add-hook 'find-file-hook #'ha-code-notes--fringe)

New notes and edited notes should refresh the markers too, so we hook into saving a notes file, and refresh right after inserting a new XREF property drawer:

  (defun ha-code-notes--fringe-notes-refresh-all ()
    "Refresh fringe note markers in every buffer after saving a notes file."
    (when (string-match (rx "-notes.org" string-end) (buffer-file-name))
      (dolist (buf (buffer-list))
        (with-current-buffer buf
          (when buffer-file-name
            (ha-code--fringe-notes))))))

  (add-hook 'after-save-hook #'ha-code-notes--fringe-notes-refresh-all)