Fix critical bugs in my ha-code-note project

This commit is contained in:
Howard Abrams 2026-10-09 19:16:35 -07:00
parent d9f3ec8806
commit 97e9b86026
3 changed files with 144 additions and 58 deletions

View file

@ -210,7 +210,6 @@ The following /defines/ the rest of my org-mode literate files, that I load late
"ha-org-literate.org" "ha-org-literate.org"
"ha-org-clipboard.org" "ha-org-clipboard.org"
"ha-capturing-notes.org" "ha-capturing-notes.org"
"ha-code-notes.org"
"ha-agendas.org" "ha-agendas.org"
"ha-data.org" "ha-data.org"
"ha-passwords.org" "ha-passwords.org"
@ -230,6 +229,7 @@ The following /defines/ the rest of my org-mode literate files, that I load late
"ha-org-publishing.org" "ha-org-publishing.org"
"ha-email.org" "ha-email.org"
"ha-aux-apps.org")) "ha-aux-apps.org"))
"ha-code-notes.org"
"ha-dashboard.org")) "ha-dashboard.org"))
"List of org files that complete the hamacs project.") "List of org files that complete the hamacs project.")
#+end_src #+end_src
@ -241,7 +241,7 @@ The list of /hamacs/ org-formatted files stored in =ha-hamacs-files= is selectiv
(if (not all) (if (not all)
ha-hamacs-files ha-hamacs-files
(thread-last (rx ".org" string-end) (thread-last (rx (not ".") (+? any) ".org" string-end)
(directory-files hamacs-source-dir nil) (directory-files hamacs-source-dir nil)
(append ha-hamacs-files) (append ha-hamacs-files)
(--filter (not (string-match (rx "README") it))) (--filter (not (string-match (rx "README") it)))

View file

@ -158,7 +158,7 @@ And point Emacs to it:
(setq agent-shell-anthropic-claude-acp-command (setq agent-shell-anthropic-claude-acp-command
(if (file-exists-p "/opt/homebrew") (if (file-exists-p "/opt/homebrew")
(list (file-expand-wildcards "/opt/homebrew/bin/claude-agent-acp")) (file-expand-wildcards "/opt/homebrew/bin/claude-agent-acp")
'("/usr/local/bin/claude-agent-acp")))) '("/usr/local/bin/claude-agent-acp"))))
#+END_SRC #+END_SRC

View file

@ -63,6 +63,22 @@ This allows me to /wax poetic/ with a parallel, but separate org file. Of course
If this isn’t good for you, check out [[https://github.com/agoodman42/montag][Montag]]. Same project, different approach using a single directory for all notes with [[https://www.orgroam.com/][org-roam]]. If this isn’t good for you, check out [[https://github.com/agoodman42/montag][Montag]]. Same project, different approach using a single directory for all notes with [[https://www.orgroam.com/][org-roam]].
Let’s create a pattern for what a notes file is, and how to recognize them:
#+BEGIN_SRC emacs-lisp
(defconst ha-code-notes-rx (rx (optional "/")
"." (one-or-more (not "/")) "-notes.org" string-end))
(defun ha-code-notes-file (file &optional parent)
"Return a FILE's 'code notes' filename.
Prepend the PARENT if given."
(cond
((and parent (string-match-p (rx "/" string-end) parent))
(format "%s.%s-notes.org" parent file))
(parent (format "%s/.%s-notes.org" parent file))
(t (format ".%s-notes.org" file))))
#+END_SRC
Another feature when looking at the notes in the org buffer, is to hit the same key to return to the source code file referencing the notes. Another feature when looking at the notes in the org buffer, is to hit the same key to return to the source code file referencing the notes.
# TODO Create custom settings to override default behavior. # TODO Create custom settings to override default behavior.
@ -109,17 +125,18 @@ This was far gnarlier than I originally thought, since IMenu only wants to /inte
(file qualified-name offset)" (file qualified-name offset)"
(let* ((here (point)) (let* ((here (point))
(entry (thread-last (imenu--make-index-alist t) (entry (ignore-errors
;; Flatten hierarchy labels with dot separators: (thread-last (imenu--make-index-alist t)
(ha-code-notes-location--flatten-index) ;; Flatten hierarchy labels with dot separators:
;; Filter to only entries that occur after point: (ha-code-notes-location--flatten-index)
(seq-filter (lambda (e) (<= (cdr e) here))) ;; Filter to only entries that occur after point:
;; Sort the remaining entries by position (seq-filter (lambda (e) (<= (cdr e) here)))
;; (since out flatten-index function ;; Sort the remaining entries by position
;; doesn't guarantee the order): ;; (since out flatten-index function
(seq-sort-by #'cdr #'>) ;; doesn't guarantee the order):
;; And the first entry on the list is our goal: (seq-sort-by #'cdr #'>)
car))) ;; And the first entry on the list is our goal:
car))))
(unless entry (unless entry
(error "No enclosing definition found for point")) (error "No enclosing definition found for point"))
(list (buffer-file-name) (list (buffer-file-name)
@ -131,8 +148,8 @@ The needed /trick/ is to flatten this IMenu hierarchy using this recursive beaut
#+BEGIN_SRC emacs-lisp #+BEGIN_SRC emacs-lisp
(defun ha-code-notes-location--flatten-index (alist &optional prefix) (defun ha-code-notes-location--flatten-index (alist &optional prefix)
"Flatten imenu ALIST into a list of (QUALIFIED-NAME . POSITION). "Flatten imenu ALIST into a list of (NAME . POSITION).
Where the qualified-name matches what which-function/imenu expect. Where the name matches what which-function/imenu expect.
For instance: For instance:
@ -142,47 +159,115 @@ The needed /trick/ is to flatten this IMenu hierarchy using this recursive beaut
((\"foo_class.method_one\" ...) (\"foo_class.method_two\" ...)...) ((\"foo_class.method_one\" ...) (\"foo_class.method_two\" ...)...)
This is called recursively, so PREFIX could be a class name, or Called recursively, where PREFIX could be a class name, or other higher
other higher abstraction." abstraction when the nesting represents a real outline (Org, Markdown).
;; Like `mapconcat' but lets us concat into something other than a string.
;; Note that `mapcan' uses `nconc' which _mutates_ the `alist'. Entries tagged with `imenu-kind' or `breadcrumb-kind' property (as Eglot
;; This is fine as a parameter that we then return: nests a function's local variables for Python) are already a flat
namespace of functions/variables/constants, so their own names are kept
bare and not qualified by an enclosing function's name."
(mapcan (mapcan
(lambda (entry) (lambda (entry)
;; Some imenu backends (e.g. python.el) annotate names with their (unless (or (null entry) (atom entry))
;; category, e.g. "Pair (class)" or "sum (def)". Strip that so (let* ((raw-name (car entry))
;; names match what `which-function'/`add-log-current-defun' (kind (and (stringp raw-name)
;; produce, and what gets stored as XREF_NAME in notes files. (or (get-text-property 0 'imenu-kind raw-name)
(let* ((name (replace-regexp-in-string (get-text-property 0 'breadcrumb-kind raw-name))))
(rx space "(" (one-or-more alpha) ")" string-end) (name (if (stringp raw-name)
"" (car entry))) (replace-regexp-in-string
(value (cdr entry)) (rx space "(" (one-or-more alpha) ")" string-end)
(qualified (if prefix (format "%s.%s" prefix name) name))) "" (substring-no-properties raw-name))
(if (listp value) (format "%s" raw-name)))
(ha-code-notes-location--flatten-index value qualified) (value (cdr entry))
(list (cons qualified (if (markerp value) (marker-position value) value)))))) (qualified (if (and prefix (not kind)) (format "%s.%s" prefix name) name)))
(cond
;; Case 1: Nested submenu (list of alist entries). Eglot
;; never gives the container itself a position among its
;; children, as Org does; it stashes it in the
;; `imenu-region' text property instead, so recover it
;; here. Children only inherit QUALIFIED as their prefix
;; when this entry has no `imenu-kind' of its own -- i.e.
;; when the nesting is a real outline, not Eglot's flat
;; function/variable/constant tagging.
((and (listp value) (consp (car value)))
(append
(let ((region (and (stringp raw-name)
(get-text-property 0 'imenu-region raw-name))))
(when region (list (cons qualified (car region)))))
(ha-code-notes-location--flatten-index value (unless kind qualified))))
;; Case 2: Direct marker or integer position
((integer-or-marker-p value)
(list (cons qualified (if (markerp value) (marker-position value) value))))
;; Case 3: Overlay or element containing a position in car/cdr
((overlayp value)
(list (cons qualified (overlay-start value))))
((and (consp value) (integer-or-marker-p (car value)))
(list (cons qualified (if (markerp (car value))
(marker-position (car value))
(car value)))))
;; Fallback: ignore non-positional Imenu metadata entries
(t nil)))))
alist)) alist))
#+END_SRC #+END_SRC
This flatten feature has been a pain in my side. An org-mode formatted file works well, for instance, running the following on this document:
#+BEGIN_SRC emacs-lisp :tangle no :results replace value raw :export both :wrap example
(ha-code-notes-location--flatten-index (imenu--make-index-alist t))
#+END_SRC
#+RESULTS:
#+begin_example
((*Rescan* . -99) (Introduction . 946) (Helper Functions.Relative Code Locations . 3719) (Helper Functions.Org Properties . 13220) (Helper Functions . 3592) (Write a Note.Return to Source Code . 22408) (Write a Note.Fringe Indicators for Notes . 24001) (Write a Note . 17005) (Technical Artifacts . 27464))
#+end_example
But running the same expression on a Python file (via Eglot) nests the local variables and parameters of each function as children in the index, each tagged with an =imenu-kind= text property on its name, e.g. a function like this:
#+begin_example
(#("parse_args" 0 10 (imenu-region (1245 . 1627) imenu-kind "Function" breadcrumb-region (1245 . 1627) breadcrumb-kind "Function"))
(#("argv" 0 4 (imenu-region (1260 . 1289) imenu-kind "Variable" ...)) . 1260)
(#("parser" 0 6 (imenu-region (1365 . 1371) imenu-kind "Variable" ...)) . 1365))
#+end_example
Naively flattening this treats =argv= and =parser= as if they were nested methods, producing useless entries like ~parse_args.argv~. Worse, the function itself never had its own position among its children (unlike Org, which gives a parent headline a self-pointing entry), so there was no way to jump straight to ~parse_args~ at all.
The fix: the presence of an =imenu-kind=/=breadcrumb-kind= property means the nesting is Eglot's flat function/variable/constant tagging, not a real outline -- so a child's name is never qualified by its parent's. When recursing into a nested submenu, the container's own position is recovered from its =imenu-region= text property, and the name is stripped of text properties. That turns the above into clean, bare entries for every name -- function, variable, and constant alike:
#+begin_example
((parse_args . 1245) (argv . 1260) (parser . 1365) (registry_credentials . 1630) ...)
#+end_example
Once inside a source file, we can jump to one of these /relative locations/: Once inside a source file, we can jump to one of these /relative locations/:
#+BEGIN_SRC emacs-lisp #+BEGIN_SRC emacs-lisp
(defun ha-code-notes-location-goto (file name offset) (defun ha-code-notes-location-goto (file name offset)
"Move point to the position described by relative location. "Move point to the position described by relative location.
FILE is the filename to load, may be nil to use current buffer. FILE is the filename to load, may be nil to use current buffer.
NAME is a function name or other definition, e.g. ClassName.method NAME is a function name or other definition, e.g. ClassName.method
OFFSET is the number of lines below NAME to position the point." OFFSET is the number of lines below NAME to position the point."
(when (and file (not (equal file (buffer-file-name)))) (when (and file (not (equal file (buffer-file-name))))
(find-file-other-window file)) (find-file-other-window file))
(condition-case t
(ha-code-notes--location-goto name offset)
(message "Definition `%s' no longer found in %s" name file))
(ignore-errors
))
(defun ha-code-notes--location-goto (name &optional offset)
"Use `imenu' interface to goto NAME location.
If OFFSET given, move that many lines below."
(let* ((index (ha-code-notes-location--flatten-index (imenu--make-index-alist t))) (let* ((index (ha-code-notes-location--flatten-index (imenu--make-index-alist t)))
(entry (assoc name index))) (entry (assoc name index)))
(if (not entry) (when entry
(message "Definition `%s' no longer found in %s" name file)
(goto-char (cdr entry)) (goto-char (cdr entry))
(when offset (when offset
(forward-line offset))))) (forward-line offset)))))
#+END_SRC #+END_SRC
To verify that this works: To verify that this works:
@ -297,8 +382,7 @@ This function defines what the notes filename should look like, loads it in a si
;; Keep in mind the `orig-parent' has a final slash, so the ;; Keep in mind the `orig-parent' has a final slash, so the
;; initial . here marks it as hidden: ;; initial . here marks it as hidden:
(note-file (format "%s.%s-notes.org" (note-file (ha-code-notes-file orig-base orig-parent))
orig-parent orig-base))
(header (which-function)) (header (which-function))
(offset (ha-code-notes-defun-rel-line))) (offset (ha-code-notes-defun-rel-line)))
@ -322,7 +406,7 @@ Use the builtin autoinsert feature to inject a basic template at the beginning o
(use-package autoinsert (use-package autoinsert
:config :config
(define-auto-insert (define-auto-insert
(cons (rx "/." (one-or-more (not "/")) "-notes.org" string-end) "Org Notes Template") (cons ha-code-notes-rx "Org Notes Template")
'("Short description: " '("Short description: "
"#+TITLE: " "#+TITLE: "
(s-titleized-words (s-replace-regexp (rx (any "-" "_")) " " (s-titleized-words (s-replace-regexp (rx (any "-" "_")) " "
@ -435,10 +519,7 @@ And give us keybinding that will either go to the notes (if we are in some code)
"Open the notes buffer, or return to the code." "Open the notes buffer, or return to the code."
(interactive) (interactive)
(when (buffer-file-name) (when (buffer-file-name)
(if (string-match (rx "/." ; A hidden file (if (string-match ha-code-notes-rx (buffer-file-name))
(one-or-more (not "/"))
"-notes.org" string-end)
(buffer-file-name))
(ha-code-notes-return) (ha-code-notes-return)
(ha-code-notes)))) (ha-code-notes))))
@ -472,22 +553,27 @@ A code file with an associated notes file is easy to forget about. Let's mark, i
(defun ha-code-notes--fringe () (defun ha-code-notes--fringe ()
"Mark, in the fringe, every line in this buffer that has a note. "Mark, in the fringe, every line in this buffer that has a note.
Notes live in the paired `.BASE-notes.org' file (see Notes live in the paired `.BASE-notes.org' file (see
`ha-code-notes') as headings whose XREF property matches this `ha-code-notes') as headings whose XREF property matches this
file, XREF_NAME names the function (inherited by subheadings from file, XREF_NAME names the function (inherited by subheadings from
their parent), and XREF_LINE is a line offset from that function's their parent), and XREF_LINE is a line offset from that function's
start, computed by `ha-code-notes-defun-rel-line'. We resolve each start, computed by `ha-code-notes-defun-rel-line'. We resolve each
pair back to an absolute line via `ha-code-notes-location-goto', since the function pair back to an absolute line via `ha-code-notes-location-goto', since the function
may have moved since the note was taken." may have moved since the note was taken."
(mapc #'delete-overlay ha-code-notes--fringe-overlays) (mapc #'delete-overlay ha-code-notes--fringe-overlays)
(setq ha-code-notes--fringe-overlays nil) (setq ha-code-notes--fringe-overlays nil)
(let* ((orig-file (buffer-file-name)) (let* ((orig-file (buffer-file-name))
(note-file (and orig-file (note-file (and orig-file
(format "%s.%s-notes.org" (ha-code-notes-file
(file-name-directory orig-file) (file-name-directory orig-file)
(file-name-base orig-file)))) (file-name-base orig-file))))
notes) notes)
(when (and note-file (file-exists-p note-file)) ;; Only run if this is an actual file AND its notes file exists:
(when (and orig-file
note-file
(file-exists-p note-file)
(not (string-match-p ha-code-notes-rx orig-file)))
(with-temp-buffer (with-temp-buffer
(insert-file-contents note-file) (insert-file-contents note-file)
(org-mode) (org-mode)
@ -516,7 +602,7 @@ New notes and edited notes should refresh the markers too, so we hook into savin
#+BEGIN_SRC emacs-lisp #+BEGIN_SRC emacs-lisp
(defun ha-code-notes--fringe-notes-refresh-all () (defun ha-code-notes--fringe-notes-refresh-all ()
"Refresh fringe note markers in every buffer after saving a notes file." "Refresh fringe note markers in every buffer after saving a notes file."
(when (string-match (rx "-notes.org" string-end) (buffer-file-name)) (when (string-match ha-code-notes-rx (buffer-file-name))
(dolist (buf (buffer-list)) (dolist (buf (buffer-list))
(with-current-buffer buf (with-current-buffer buf
(when buffer-file-name (when buffer-file-name