Skip to content

Latest commit

 

History

History
1960 lines (1748 loc) · 85.1 KB

File metadata and controls

1960 lines (1748 loc) · 85.1 KB

Interface Tweaks

;;; config.el -*- lexical-binding: t; -*-
;;;
;;;
;;; BEGIN_Interface_Tweaks
;;;
;;;

(add-to-list 'initial-frame-alist '(fullscreen . fullboth))
(push '(fullscreen . fullboth)   default-frame-alist)

;; Banner made with "convert image.png -resize 600 -quality 75 OUTPUT.png"
;; (-resize 400 for 1080p).
(setopt fancy-splash-image (expand-file-name "banner/trancendent-gnu.png" doom-user-dir))

;; Doom applies these in `doom-init-fonts-h': on `after-init-hook' normally, or
;; on `server-after-make-frame-hook' under a daemon, where no frame -- and so no
;; font -- exists until the first client connects.  Each accepts a font-spec, a
;; font string ("Input Mono-12"), or an XLFD string.
;; + `doom-font'                 default and fixed-pitch faces
;; + `doom-variable-pitch-font'  variable-pitch face
;; + `doom-serif-font'           fixed-pitch-serif face
;; + `doom-big-font'             `doom-big-font-mode', for presentations
;; + `doom-symbol-font'          the `symbol' and `mathematical' scripts
;; + `doom-emoji-font'           the `emoji' script
;; The last two fall back to `doom-symbol-fallback-font-families' and
;; `doom-emoji-fallback-font-families' when left unset.

(setopt doom-theme 'doom-dracula)

(setopt doom-font (font-spec :family "FiraCode Nerd Font Mono" :size 13)
       doom-variable-pitch-font (font-spec :family "Fira Sans" :size 13)
       doom-big-font (font-spec :family "FiraCode Nerd Font Mono" :size 19)
       doom-serif-font (font-spec :family "Source Serif 4" :size 13)
       ;; No :size -- colour emoji scale from the default face height.
       doom-emoji-font (font-spec :family "Noto Color Emoji"))

;; `doom-symbol-font' deliberately NOT set, `doom-emoji-font' deliberately IS.
;; `doom-init-fonts-h' applies the face fonts first and the fontsets afterwards,
;; and a frame resolves glyphs through the fontset derived when its faces were
;; set -- so on a daemon's first frame the emoji entry lands too late and emoji
;; fall through to Symbola in monochrome.  Measured on U+1F600: Font Awesome 7
;; Free with the symbol font set, Symbola with it merely removed, Noto Color
;; Emoji only once the faces are re-applied over the finished fontset.  One
;; re-application suffices; it removes itself because `set-fontset-font' should
;; not run per frame (Doom notes superfluous calls can segfault).
(defun bmg/reload-fonts-once (&rest _)
  "Re-apply fonts once a graphical frame exists, then stand down."
  (when (display-graphic-p)
    (remove-hook 'server-after-make-frame-hook #'bmg/reload-fonts-once)
    (remove-hook 'after-init-hook #'bmg/reload-fonts-once)
    (doom/reload-font)))
(add-hook 'server-after-make-frame-hook #'bmg/reload-fonts-once 90)
(add-hook 'after-init-hook #'bmg/reload-fonts-once 90)

(with-eval-after-load 'doom-themes
  (setopt doom-themes-enable-bold t
         doom-themes-enable-italic t))

;; doom-dracula paints comments in `base5' (#6272a4): 3.03:1 on the #282a36
;; background, below WCAG AA (4.5:1) and barely over the 3:1 large-text floor.
(custom-set-faces!
  '(font-lock-comment-face           :foreground "#b8c0dc")    ; 7.87:1 on #282a36
  '(font-lock-comment-delimiter-face :foreground "#b8c0dc")    ; 7.87:1 on #282a36
  ;; Doom sets `avy-background' t, so a jump repaints every visible character
  ;; in this face; dracula leaves it at base5 (#6272a4), 3.03:1.  Dim enough
  ;; against 13.36:1 body text to mark the mode, still over WCAG AA.
  '(avy-background-face              :foreground "#9aa2c4"))   ; 5.65:1 on #282a36

;; Magit diff legibility.  `diff-refine-added' and `diff-refine-removed' ship
;; with `:inverse-video t', so foreground and background swap at render time:
;; doom-dracula defines refine-added as dark #1E2029 bg with #50fa7b text, but
;; it *paints* as a solid #50fa7b block with near-black glyphs.
;; `magit-diff-refine-hunk' is t, so this hits the hunk at point only.
;;
;; Dracula's removed faces also fail WCAG on their own, before any inversion:
;;   magit-diff-removed            #cc4444 on #564859 = 1.82:1
;;   magit-diff-removed-highlight  #ff5555 on #694959 = 2.48:1
;;   diff-refine-removed (as rendered) #44475a on #ff5555 = 2.91:1
;; `custom-set-faces!' re-applies on `doom-load-theme-hook', so these survive a
;; theme reload -- but they ARE dracula-specific, so revisit on a theme change.
(custom-set-faces!
  ;; Word-level refinement gets NO background of its own -- a changed word is
  ;; just a brighter, bolder version of the line's own colour.  Bold carries
  ;; most of the signal, so the ~1.12x luminance step only has to catch the
  ;; eye, not shout -- keep the hue, lift it slightly.  `:inherit nil'
  ;; is the load-bearing part: `:background unspecified' alone does NOT mean "no
  ;; background", it means "not set here", so the attribute falls through to
  ;; `:inherit', and doom-dracula points these faces at `diff-added'/
  ;; `diff-removed', whose #1E2029 and #44475a backgrounds were the box behind
  ;; each changed word.  `:inverse-video nil' overrides upstream
  ;; `diff-refine-changed', which sets it; `:underline nil' is explicit rather
  ;; than omitted so that re-evaluating this block in a running session clears a
  ;; previously applied underline.
  '(diff-refine-added
    :inherit nil :inverse-video nil :background unspecified
    :foreground "#96f5ba" :weight bold :underline nil)   ; 1.12x line fg, 10.50:1 on #17331f
  '(diff-refine-removed
    :inherit nil :inverse-video nil :background unspecified
    :foreground "#fab0b0" :weight bold :underline nil)   ; 1.13x line fg, 8.02:1 on #42212b
  ;; smerge's refine faces inherit the same way, in conflict resolution.
  '(smerge-refined-added
    :inherit nil :inverse-video nil :background unspecified
    :foreground "#96f5ba" :weight bold :underline nil)
  '(smerge-refined-removed
    :inherit nil :inverse-video nil :background unspecified
    :foreground "#fab0b0" :weight bold :underline nil)
  ;; Line-level faces.  `magit-diff-highlight-hunk-body' nil (below) leaves the
  ;; "-highlight" variants unused, but they stay correct in case it flips back.
  '(magit-diff-added             :background "#17331f" :foreground "#7aeda4")    ; 9.46:1
  '(magit-diff-added-highlight   :background "#1f5233" :foreground "#93ffb9")    ; 7.46:1
  '(magit-diff-removed           :background "#42212b" :foreground "#f5a3a3")    ; 7.18:1
  '(magit-diff-removed-highlight :background "#5c2836" :foreground "#ffbcbc"))   ; 7.27:1

;; Show hunk focus with the hunk *heading* alone, not by repainting the body:
;; by default Magit swaps every line of the hunk at point to the brighter
;; "-highlight" faces.  `magit-section-paint' clears the highlight flag only
;; after `forward-line', so the heading is painted separately and keeps
;; `magit-diff-hunk-heading-highlight' (the purple bar) regardless.
(with-eval-after-load 'magit-diff
  (setopt magit-diff-highlight-hunk-body nil))

;; Break a gnus face inheritance cycle: gnus-group-news-low-empty inherits from
;; gnus-group-news-low, which inherits from gnus-group-news-low-empty.
(with-eval-after-load 'gnus
  (set-face-attribute 'gnus-group-news-low nil
                      :weight 'normal
                      :foreground "#565761")  ; doom-dracula base4, 1.99:1 on #282a36
  (set-face-attribute 'gnus-group-news-low-empty nil
                      :weight 'normal
                      ;; Not a doom-dracula palette colour: sits between base2
                      ;; (#373844) and base3 (#44475a), a step dimmer than the
                      ;; non-empty face above.
                      :foreground "#3d3f4c")) ; 1.37:1 on #282a36

(with-eval-after-load 'org-modern
  (set-face-attribute 'org-modern-symbol nil :family "Font Awesome 7 Free")
  (set-face-attribute 'org-modern-label nil :height 1.0)
  (set-face-attribute 'org-modern-block-name nil :height 1.0))

;; be as colorful as possible.
(setopt treesit-font-lock-level 4)

(setopt display-line-numbers-type t)

;; Blinking cursors are annoying
(blink-cursor-mode -1)

(setq +dashboard-menu-sections
       '(("Open org-agenda"
          :icon (nerd-icons-octicon "nf-oct-calendar" :face '+dashboard-menu-title)
          :action bmg/switch-to-agenda)
         ("Recently opened files"
          :icon (nerd-icons-faicon "nf-fa-file_text" :face '+dashboard-menu-title)
          :action recentf-open-files)
         ("Reload last session"
          :icon (nerd-icons-octicon "nf-oct-history" :face '+dashboard-menu-title)
          :when (cond ((modulep! :ui workspaces)
                       (file-exists-p (expand-file-name persp-auto-save-fname persp-save-dir)))
                      ((require 'desktop nil t)
                       (file-exists-p (desktop-full-file-name))))
          :action doom/quickload-session)
         ("Open project"
          :icon (nerd-icons-octicon "nf-oct-briefcase" :face '+dashboard-menu-title)
          :action projectile-switch-project)
         ("Open private configuration"
          :icon (nerd-icons-octicon "nf-oct-tools" :face '+dashboard-menu-title)
          :when (file-directory-p doom-user-dir)
          :action doom/open-private-config)
         ("Open documentation"
          :icon (nerd-icons-octicon "nf-oct-book" :face '+dashboard-menu-title)
          :action doom/help)
         ("RSS"
          :icon (nerd-icons-octicon "nf-oct-rss" :face '+dashboard-menu-title)
          :action =rss)
         ))
;;;
;;;
;;; END_Interface_Tweaks
;;;
;;;

General

;;;
;;;
;;; BEGIN_General
;;;
;;;

;; Used by GPG, email clients, file templates and snippets.
(setopt user-full-name "Brian McGillion"
       user-mail-address "brian@ssrc.tii.ae"
       which-key-idle-delay 0.5
       auth-sources '("~/.authinfo.gpg" "~/.netrc")
       ;; M-x projectile-discover-projects-in-directory
       ;; M-x projectile-discover-projects-in-search-path
       projectile-project-search-path '(("~/.dotfiles" . 1)("~/projects" . 6)("~/.config" . 2)("~/Documents/org" . 2))
       projectile-auto-discover nil)

;; Project discovery walks *every* subdirectory until DEPTH runs out, with no
;; ignore list, and `file-directory-p' follows symlinks -- so
;; `.direnv/flake-inputs/<hash>-source' drags the scan into /nix/store and it
;; starts indexing nixpkgs.  Upstream also only tests for a project at *exactly*
;; DEPTH levels down, never on the way, so shallow projects under a deep search
;; path are missed.  Both are fixed here: prune the noise directories, and
;; register a directory as soon as it is itself a project root instead of
;; descending through it.
(defvar bmg/projectile-discovery-ignored-directories
  '(".direnv" ".devenv" ".git" ".jj" ".hg" ".svn" ".cache" ".venv"
    "node_modules" "result")
  "Directory names `projectile' project discovery must never descend into.")

(defun bmg/projectile-project-root-p (directory)
  "Return non-nil if DIRECTORY is itself the root of a project."
  (let ((root (ignore-errors (projectile-project-root directory))))
    (and root
         (equal (file-truename (file-name-as-directory directory))
                (file-truename root)))))

(with-eval-after-load 'projectile
  ;; Also keep them out of in-project file listings. A leading `*' means
  ;; "ignore anywhere", not just at the project top level.
  (dolist (dir '("*.direnv" "*.devenv"))
    (cl-pushnew dir projectile-globally-ignored-directories :test #'equal))

  (define-advice projectile-discover-projects-in-directory
      (:around (fn directory &optional depth) bmg/prune-discovery)
    "Skip noise directories, and stop descending at project roots."
    (unless (member (file-name-nondirectory (directory-file-name directory))
                    bmg/projectile-discovery-ignored-directories)
      (if (and (> (or depth 1) 0)
               (file-directory-p directory)
               (bmg/projectile-project-root-p directory))
          ;; DIRECTORY is a project root: record it, don't walk into it.
          (funcall fn directory 0)
        (funcall fn directory depth)))))

(add-hook 'text-mode-hook #'visual-line-mode)

(global-auto-revert-mode t)

(with-eval-after-load 'dirvish
  (dirvish-side-follow-mode t)
  (setopt dirvish-hide-details '(dirvish dirvish-side))
  ;; The side panel doesn't always reach `dirvish--build-layout', so
  ;; `dired-hide-details-mode' never gets toggled on.
  (add-hook 'dirvish-find-entry-hook
            (defun bmg/dirvish-side-hide-details (_path find-fn)
              (when (and (eq find-fn 'dired)
                         (when-let* ((dv (dirvish-curr)))
                           (eq (dv-type dv) 'side)))
                (dired-hide-details-mode 1))
              nil)))

;; Don't reroot the side panel into the roam tree: roam/projects/<project>/
;; shares a basename with the code checkout, and dirvish renames side buffers
;; non-uniquely (basename + second), so rerooting twice in a second errors.
(with-eval-after-load 'dirvish-side
  (advice-add 'dirvish-side--auto-jump :before-while
              (defun bmg/dirvish-side-skip-org-roam ()
                (not (and buffer-file-name
                          (bound-and-true-p org-roam-directory)
                          (file-in-directory-p buffer-file-name org-roam-directory))))))

;; Doom's :checkers spell is disabled in init.el; jinx replaces it.  jinx links
;; libenchant, which reads the same aspell dictionaries installed from
;; ~/.dotfiles.  The package comes from emacsWithPackages, so it needs no
;; `package!' declaration -- it is already on `load-path'.
(use-package jinx
  :hook (doom-first-buffer . global-jinx-mode)
  :config
  ;; enchant checks both at once: en_GB primary, US spellings accepted.
  (setopt jinx-languages "en_GB en_US")
  ;; The default list covers neither nix nor elisp.  t splits camelCase in every
  ;; mode, which is what lets `callPackage' and `mkDefault' pass.
  (setopt jinx-camel-modes t)
  ;; Flake refs (github:owner/repo) sit in strings and commented-out code, which
  ;; jinx checks as prose.  Skip the whole token.  `jinx--mode-list' appends to
  ;; the `t' defaults, so jinx's own exclusions still apply.
  (add-to-list 'jinx-exclude-regexps
               '(nix-ts-mode "[a-z][a-z0-9+.-]*:[^\"[:space:]]*/[^\"[:space:]]*")))

;; ispell is still reachable via M-x ispell-buffer; M-$ goes to jinx below.
(setopt ispell-dictionary "en_GB")

;; NixOS has no /usr/share/dict/words.  aspell still spell-checks; this only
;; affects M-TAB word completion.
(setopt text-mode-ispell-word-completion nil)

(set-language-environment-charset "UTF-8")

;; Use the Languagetool only in server mode
(setopt langtool-http-server-host "localhost"
       langtool-http-server-port 8081
       langtool-default-language nil)

;; harper-ls grammar-checks comment text as you type; LanguageTool stays manual
;; (C-c g c) for prose.  harper has no parser for elisp, yaml or json -- it
;; falls back to linting those files as plain English -- so activation is an
;; explicit whitelist rather than all of prog-mode.
(defvar bmg/harper-modes
  '(nix-ts-mode
    c-mode c-ts-mode c++-mode c++-ts-mode
    rust-mode rust-ts-mode
    python-mode python-ts-mode
    sh-mode bash-ts-mode
    js-mode js-ts-mode typescript-ts-mode tsx-ts-mode
    go-mode go-ts-mode
    toml-ts-mode)
  "Major modes where harper-ls has a real parser and lints only comments.")

(with-eval-after-load 'lsp-mode
  ;; jinx owns spelling.  harper's SpellCheck duplicates it on real typos and
  ;; regresses it otherwise -- no camelCase splitting (`mkDefault' -> "did you
  ;; mean default?") and a separate dictionary that lacks the project jargon.
  ;; The rest are prose rules that misfire on code comments: TODO is a code
  ;; convention not "to-do"; commented-out code trips the capitalisation rules
  ;; (github:, url =, lsp, cli); SPDX year ranges trip the en-dash rule.
  (lsp-register-custom-settings
   '(("harper-ls.linters.SpellCheck" nil t)
     ("harper-ls.linters.ToDoHyphen" nil t)
     ("harper-ls.linters.OrthographicConsistency" nil t)
     ("harper-ls.linters.SentenceCapitalization" nil t)
     ("harper-ls.linters.NumericRangeEnDash" nil t)))

  ;; :add-on? runs harper-ls beside the primary server (nixd, clangd, ...)
  ;; instead of competing with it for the buffer.
  (lsp-register-client
   (make-lsp-client
    :new-connection (lsp-stdio-connection '("harper-ls" "--stdio"))
    :activation-fn (lambda (_filename mode) (memq mode bmg/harper-modes))
    :add-on? t
    :priority -1
    :server-id 'harper-ls
    :initialized-fn (lambda (workspace)
                      (with-lsp-workspace workspace
                        (lsp--set-configuration
                         (lsp-configuration-section "harper-ls")))))))

(with-eval-after-load 'license-snippets
  (license-snippets-init))

(load! "elfeed-config.el")

;; misc selection of useful functions
(use-package crux
  :defer t)

;; https://github.com/NinjaTrappeur/my-repo-pins -- clone a repo, or jump to it
;; under `my-repo-pins-code-root'.
(use-package my-repo-pins
  :commands (my-repo-pins)
  :init
  (setopt my-repo-pins-code-root "~/projects/code"))

(use-package inheritenv
  :config
  ;; Carry the environment into called subshells; tested against go-mode's
  ;; go-import-add.
  (inheritenv-add-advice #'process-lines)
  (inheritenv-add-advice #'shell-command-to-string))

(with-eval-after-load 'consult
  ;; Live preview without pressing C-SPC every time -- overriding the override
  ;; in vertico/config.el.
  (consult-customize
   +default/search-project +default/search-other-project
   +default/search-project-for-symbol-at-point
   +default/search-cwd +default/search-other-cwd
   +default/search-notes-for-symbol-at-point
   +default/search-emacsd
   consult-ripgrep consult-git-grep consult-grep
   consult-bookmark consult-recent-file
   consult-source-recent-file consult-source-project-recent-file consult-source-bookmark
   ;; Listed in their own right: `consult-org-roam-search' *calls*
   ;; `consult-ripgrep', but consult resolves this against `this-command', so
   ;; the entry above never applies and it falls back to `consult-preview-key'.
   consult-org-roam-search consult-org-roam-file-find
   consult-org-roam-backlinks consult-org-roam-forward-links
   :preview-key (list "C-SPC" :debounce 0.1 'any))

  ;; Same live preview as above, except on documents.  rga reports line numbers
  ;; of the *extracted text*, not positions Emacs can visit, so auto-preview
  ;; spawns an epdfinfo render to land somewhere meaningless.
  ;;
  ;; (:keys ... :predicate ...) is consult's per-candidate form: consult.el:2814
  ;; registers the keymap, consult.el:1733 calls :predicate per candidate.
  ;; `consult--prefix-group' is the file name set by `consult--grep-format';
  ;; defaulting to "" previews unknown candidates, the safe direction.
  (consult-customize
   bmg/search-knowledge-base bmg/rga-search
   :preview-key
   (list :keys (list "C-SPC" :debounce 0.1 'any)
         :predicate
         (lambda (cand)
           ;; Every format an rga adapter extracts, not just documents --
           ;; all are binaries Emacs cannot preview by opening at a line.
           ;; `case-fold-search' bound so `.PDF' matches by design rather
           ;; than by whatever the calling buffer happened to have.
           (if (let ((case-fold-search t))
                 (string-match-p
                  "\\.\\(pdf\\|epub\\|docx\\|odt\\|fb2\\|ipynb\\|rtf\\|zip\\|tar\\|gz\\|bz2\\|xz\\|zst\\|sqlite3?\\|db\\|mbox\\|mkv\\|mp4\\|avi\\|mov\\|webm\\)\\'"
                  (or (get-text-property 0 'consult--prefix-group cand) "")))
               "C-SPC"
             (list "C-SPC" :debounce 0.1 'any))))))

;; Persistent directory, so a doom sync cannot remove the binary from a running
;; daemon.  'download rather than 'ask: fetch the latest version unprompted.
(setopt ghostel-module-directory (file-name-concat doom-data-dir "ghostel/"))
(setopt ghostel-module-auto-install 'download)

;; Don't let a stray keystroke take the daemon down with every client on it.
;;
;; `C-x C-c' is NOT the risk it looks like: `save-buffers-kill-terminal' sees a
;; client frame, hands off to `server-save-buffers-kill-terminal', and with
;; `server-stop-automatically' nil that takes its `server-delete-client' branch
;; and never reaches `save-buffers-kill-emacs' -- only the client frame closes,
;; and `confirm-kill-emacs' is never consulted on that path at all.  The two
;; paths that really do end the daemon:
;;   SPC q K  `save-buffers-kill-emacs' -- honours `confirm-kill-emacs'.
;;   SPC q Q  `kill-emacs' -- a bare (interactive "P") that consults nothing,
;;            one key from SPC q q.
(defun bmg/confirm-kill-daemon (&optional prompt)
  "Ask whether to kill Emacs when running as a daemon.
Returns non-nil to allow the kill; outside a daemon it always allows.  PROMPT
is supplied by `confirm-kill-emacs'; `bmg/kill-emacs-confirm' passes none."
  (or (not (daemonp))
      (yes-or-no-p (or prompt "Really kill the Emacs daemon and every client? "))))

(setopt confirm-kill-emacs #'bmg/confirm-kill-daemon)

(defun bmg/kill-emacs-confirm ()
  "Kill Emacs, confirming first when running as a daemon.
Doom binds SPC q Q straight to `kill-emacs'.  Deliberately a command
rather than advice on `kill-emacs' itself: `save-buffers-kill-emacs'
calls `kill-emacs' after already prompting (which would ask twice), and
scripts drive `emacsclient -e \\='(kill-emacs)\\=' expecting no prompt at
all.  Guarding the keybinding leaves both alone."
  (interactive)
  (when (bmg/confirm-kill-daemon)
    (kill-emacs)))

;; Doom truncates compilation buffers from `compilation-filter-hook' via
;; `doom-comint-truncate-buffer-h', with `comint-buffer-maximum-size' set to
;; 2048 globally so a chatty process cannot grind the GC: right for a shell,
;; wrong for grep.  Buffer-local here, so shells keep Doom's cap.  Deliberately
;; not unlimited: the hook only fires above (* 80 comint-buffer-maximum-size)
;; bytes, so 100000 leaves ~8MB of headroom -- far beyond a full-corpus grep
;; (453K) while still bounding a runaway compile.  `grep-mode' derives from
;; `compilation-mode', so one hook covers both.
(add-hook 'compilation-mode-hook
          (defun bmg/compilation-keep-full-output-h ()
            "Let compilation and grep buffers keep their whole result."
            (setq-local comint-buffer-maximum-size 100000)))

;; Document search, shared by `bmg/consult-rga-args' below and by the retrieval
;; in llm-config.el.  Defined here because that file is `load!'-ed from the LLM
;; section, which comes first.
;;
;; rga caches extracted document text in ~/.cache/ripgrep-all, but only for
;; blobs under `--rga-cache-max-blob-len', which defaults to 2MB *after zstd
;; compression*.  Enough of the papers are text-dense to exceed that, and rga is
;; explicit that longer outputs "will not be cached and recomputed every time"
;; -- so every search re-extracted them: measured 4.76s per query over Papers,
;; cold or warm, against 0.85s once they cache.  EPUBs were always under the
;; limit, which is why only the PDFs were slow.
;;
;; The ceiling governs cache *writes* only, so a generous value simply stops
;; anything being excluded, and costs nothing: the whole 4.7G corpus occupies
;; 91MB of cache.  Terminal rga wants the same value in
;; ~/.config/ripgrep-all/config.jsonc as {"cache_max_blob_len": 536870912}.
(defconst bmg/rga-common-args
  "--color=never --no-heading --with-filename --line-number --rga-cache-max-blob-len=512M"
  "Flags shared by every `rga' invocation.
The file:line:text shape is parsed two ways: `bmg/consult-rga-args' adds
`--null' for `consult--grep-match-regexp', llm-config.el walks the plain
colon form with its own regexp.  Neither is a `grep-mode' buffer.")

(defvar bmg/document-search-roots
  (list "~/Documents/Papers/" "~/Documents/EPUB/")
  "Source-document directories searched by the `rga' commands.
EPUB went missing for a long time, quietly excluding 121 books and 124
PDFs -- rga reads .epub natively, so this was only ever a path gap.")

(defconst bmg/consult-rga-args
  (concat "rga --null --line-buffered --max-columns=1000 --max-columns-preview"
          " --path-separator / --smart-case --max-count=5 "
          bmg/rga-common-args)
  "Command line for `rga', in the shape `consult-ripgrep-args' takes.
Derived from `bmg/rga-common-args' so the flags shared with llm-config.el
have one home and the consult-only delta stays visible.  rga accepts every
flag stock `consult-ripgrep-args' passes to rg, `-P' included, so consult
selects PCRE and joins multi-word input with look-aheads.

The delta:
- `--null' -- `consult--grep-match-regexp' needs a NUL after the filename;
  without it every candidate is silently dropped.
- `--max-columns-preview' -- otherwise rg replaces an over-long line with a
  placeholder, and PDF paragraphs routinely pass 1000 characters.
- `--max-count=5' -- `the' over these roots is 1.8M matches uncapped and
  9,858 capped, and consult holds every one in memory.  Kept out of
  `bmg/rga-common-args' so llm-config.el keeps its own `--max-count=3'.
  See `bmg/search-knowledge-base' for how it interacts with the `#' filter.
- `--smart-case' rather than the old `-i'; narrows acronyms (`LLM' finds
  2,194 against ~3,541), so append `-i' when that bites.
- `--search-zip' dropped; rga has its own archive adapters.

Appending a flag to a query overrides any of these: consult puts user
options after this list and rg takes the last occurrence.")

(defun bmg/rga--common-root (paths)
  "Return the deepest directory containing every path in PATHS.
`consult--directory-prompt' resolves a path list against the *calling
buffer's* `default-directory' (consult.el:867-870), so binding it to this
keeps result names stable: the knowledge-base roots collapse to
~/Documents/ and a hit reads `org/roam/Foo.org:34:...'."
  (let* ((seed (expand-file-name (car paths)))
         (root (if (file-directory-p seed)
                   (file-name-as-directory seed)
                 (file-name-directory seed))))
    (dolist (p (cdr paths) root)
      (setq p (file-name-as-directory (expand-file-name p)))
      (while (not (string-prefix-p root p))
        (setq root (file-name-directory (directory-file-name root)))))))

(defun bmg/consult--rga-make-builder (paths)
  "Return consult's ripgrep command builder, driven by `rga', over PATHS.
`consult--ripgrep-make-builder' reads `consult-ripgrep-args' once, when
the builder is constructed, so rebinding it around the call is enough."
  (let ((consult-ripgrep-args bmg/consult-rga-args))
    (consult--ripgrep-make-builder paths)))

(defun bmg/rga--pdf-page (cand)
  "Return the PDF page number recorded in candidate CAND, or nil.
rga's poppler adapter prefixes each extracted line with `Page N:'; the line
number consult parses is an offset into the extracted text, not a position
Emacs can visit.  Read off the `face' boundaries `consult--grep-format'
leaves, since a path may contain a colon."
  (when cand
    (when-let* ((file-end (next-single-property-change 0 'face cand))
                ((string-suffix-p ".pdf" (substring-no-properties cand 0 file-end) t))
                (line-end (next-single-property-change (1+ file-end) 'face cand))
                (content (substring-no-properties cand (1+ line-end)))
                ((string-match "\\`Page \\([0-9]+\\):" content)))
      (string-to-number (match-string 1 content)))))

(defun bmg/consult--rga (prompt paths &optional initial)
  "Run an asynchronous `rga' search over PATHS through consult.
PROMPT and INITIAL are as for `consult--grep'.  PATHS is a list of root
strings, or a prefix-argument value, which makes `consult--directory-prompt'
ask which roots to search.

Timing delays only: Doom's 0.2/0.1 (vertico/config.el:129-132) assume a
local `rg', but one rga pass here is 1.39s.  min-input stays at Doom's 2,
matching `consult-ripgrep'.  `consult--grep' builds the pipeline
synchronously, so a `let' reaches these.

RET on a PDF lands on the matching page: `consult--grep' returns the
candidate after its `:state' has visited the file.  RET only --
`embark-consult-goto-grep' still lands on page 1."
  (unless (executable-find "rga")
    (user-error "rga (ripgrep-all) not found in PATH"))
  (require 'consult)
  (let* ((roots (and (consp paths) (stringp (car paths)) paths))
         (default-directory (if roots
                                (bmg/rga--common-root roots)
                              default-directory))
         (consult-async-min-input 2)
         (consult-async-input-debounce 0.4)
         (consult-async-input-throttle 0.8)
         (cand (consult--grep prompt #'bmg/consult--rga-make-builder
                              paths initial)))
    (when-let* ((page (bmg/rga--pdf-page cand))
                ((derived-mode-p 'pdf-view-mode)))
      (pdf-view-goto-page page))
    cand))

;;;
;;;
;;; END_General
;;;
;;;

Code

;;;
;;;
;;; BEGIN_code
;;;
;;;

;; Add handlers for SELinux files and MDX (React/JSX markdown)
(add-to-list 'auto-mode-alist '("\\.te\\'" . m4-mode))
(add-to-list 'auto-mode-alist '("\\.mdx\\'" . gfm-mode)) ;; GitHub Flavored Markdown

;; Live markdown preview in an eww buffer, no xwidgets.  `markdown-command'
;; defaults to a "markdown" binary that isn't installed; pandoc comes from
;; programs.pandoc.enable in ~/.dotfiles.
(with-eval-after-load 'markdown-mode
  (setopt markdown-command "pandoc --from=gfm --to=html5 --standalone"))

(add-hook 'after-save-hook
          'executable-make-buffer-file-executable-if-script-p)

(setopt c-default-style "linux") ;; set style to "linux"

(with-eval-after-load 'lsp-clangd
  (setopt lsp-clients-clangd-args
        '("-j=3"
          "--background-index"
          "--clang-tidy"
          "--completion-style=detailed"
          "--header-insertion=never"
          "--header-insertion-decorators=0"))
  (set-lsp-priority! 'clangd 2))

;; justl binds "e" to justl-exec-recipe itself; no extra map! needed.
;; `:commands' matters here: Doom leaves `use-package-always-defer' nil, so a
;; bare (use-package justl) loads the package at every startup.
(use-package justl
  :commands (justl justl-exec-recipe-in-dir))

(defvar bmg/dotfiles-path (expand-file-name "~/.dotfiles")
  "Path to dotfiles repository for Nix flake expressions.")

(with-eval-after-load 'lsp-mode
  (setopt lsp-nix-nixd-server-path "nixd"
        lsp-nix-nixd-formatting-command ["nixfmt"]
        lsp-nix-nixd-nixpkgs-expr (format "import (builtins.getFlake \"%s\").inputs.nixpkgs { }" bmg/dotfiles-path)
        lsp-nix-nixd-nixos-options-expr (format "(builtins.getFlake \"%s\").nixosConfigurations.arcadia.options" bmg/dotfiles-path)))
;; home-manager is integrated as a NixOS module here; a standalone config wants
;; lsp-nix-nixd-home-manager-options-expr (format "(builtins.getFlake \"%s\").homeConfigurations.\"user@host\".options" bmg/dotfiles-path)

;; NASM syntax, not gas -- .asm is ambiguous and this picks the dialect.
(use-package nasm-mode
  :mode "\\.\\(nasm\\|asm\\)\\'")

;; x86 instruction lookup.  Wants the combined "Intel 64 and IA-32 Architectures
;; Software Developer's Manual" PDF from
;; https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
(use-package x86-lookup
  :commands x86-lookup
  :config
  ;; setq, not setopt: the defcustom type is (file :must-match t) and the
  ;; SDM PDF is not always on disk, so setopt would warn at every startup.
  (setq x86-lookup-pdf (expand-file-name "~/Documents/Papers/325462-sdm-vol-1-2abcd-3abcd.pdf")))

;; Doom installs pdf-tools through straight, and `pdf-info-epdfinfo-program'
;; defaults to that build directory.  The Nix emacs ships a prebuilt epdfinfo;
;; `epdfinfo' in ~/.dotfiles puts it on PATH.  `executable-find' rather than
;; a store path: the latter changes on every nixpkgs bump.
(with-eval-after-load 'pdf-tools
  (when-let* ((server (executable-find "epdfinfo")))
    (setopt pdf-info-epdfinfo-program server)))

;; ensure that worktrees are available in magit with %
(with-eval-after-load 'magit
  (magit-add-section-hook 'magit-status-sections-hook
                          'magit-insert-worktrees
                          'magit-insert-stashes
                          'append))

;; `magit-insert-local-branches' belongs to `magit-refs-mode' (the `y' buffer)
;; and is absent from magit's own default `magit-status-sections-hook', so the
;; status buffer lists no branches.  Added after Worktrees, since branches and
;; the worktrees that check them out read well together.
(with-eval-after-load 'magit
  (magit-add-section-hook 'magit-status-sections-hook
                          'magit-insert-local-branches
                          'magit-insert-worktrees
                          'append)

  (add-to-list 'magit-section-initial-visibility-alist '(local . hide))

  (add-hook 'magit-status-mode-hook
            (defun bmg/magit-status-refs-upstream-h ()
              "Give `magit-insert-local-branches' the comparison base it expects."
              (setq-local magit-buffer-refs-upstream "HEAD")))

  (defun bmg/magit-branch-checkout-or-visit ()
    "Check out the branch at point in a status buffer; visit it elsewhere.
In `magit-refs-mode' and anywhere else this falls through to
`magit-visit-ref', preserving stock behaviour in the `y' buffer."
    (interactive)
    (if-let* (((derived-mode-p 'magit-status-mode))
              (branch (magit-section-value-if 'branch)))
        (magit-branch-checkout branch)
      (call-interactively #'magit-visit-ref)))

  (map! :map magit-branch-section-map
        [remap magit-visit-thing] #'bmg/magit-branch-checkout-or-visit))

;; Selecting a worktree in Magit only opened a status buffer for it: the Doom
;; workspace, its buffer list and the modeline all stayed on the mainline
;; checkout, so project commands run from anywhere else still searched mainline.
;;
;; `magit-diff-visit-directory' is the single funnel every worktree entry point
;; goes through -- `magit-worktree-status' (RET, % g), `magit-worktree-checkout'
;; (% b), `magit-worktree-branch' (% c), `magit-worktree-move' (% m) and the
;; return to the primary worktree after % k -- so one advice there covers all of
;; them, and the gate keeps submodules and ordinary directory visits from diffs
;; on Magit's original code path.
;;
;; Accepted consequences: RET on a worktree opens the project file picker, not
;; that worktree's Magit status -- a deliberate deviation from stock Magit,
;; traded for holding no landing logic here; `+workspace-project' is left unset, as Doom
;; does on this reuse branch; with a prefix argument the workspace is repointed
;; but no file picker opens; and buffers already visiting mainline files keep
;; reporting the mainline project, because `projectile-project-root' derives
;; from each buffer's `default-directory' -- only a separate workspace could
;; isolate those.

(defun bmg/magit-linked-worktree-p (directory)
  "Return non-nil if DIRECTORY is a worktree of this repository.
The worktree being displayed, submodules and unrelated repositories do not
count."
  (ignore-errors
    (let ((dir (file-truename (file-name-as-directory (expand-file-name directory))))
          (here (file-truename (file-name-as-directory (magit-toplevel)))))
      (and (not (equal dir here))
           (seq-some (lambda (worktree)
                       (equal dir (file-truename
                                   (file-name-as-directory (car worktree)))))
                     (magit-list-worktrees))))))

;; direnv keys its allow list on the *absolute path* of the .envrc, not its
;; contents, so a worktree of a repo you already trust arrives unallowed even
;; though the .envrc is tracked and byte-identical.
(defun bmg/magit-worktree-allow-direnv (root)
  "Allow ROOT's .envrc if it has one, so its devshell can load."
  (when (and (executable-find "direnv")
             (file-exists-p (expand-file-name ".envrc" root)))
    (if (zerop (call-process "direnv" nil nil nil "allow" root))
        (message "direnv allowed: %s" (abbreviate-file-name root))
      (message "direnv allow failed in %s" (abbreviate-file-name root)))))

(defun bmg/magit-switch-project-to-worktree (worktree)
  "Make WORKTREE the current project, reusing the current Doom workspace."
  (let ((root (file-name-as-directory (expand-file-name worktree)))
        (+workspaces-on-switch-project-behavior nil))
    ;; Before the switch: envrc evaluates on the first file visited in the new
    ;; project, which projectile is about to trigger.
    (bmg/magit-worktree-allow-direnv root)
    (projectile-switch-project-by-name root)))

(with-eval-after-load 'magit-diff
  (define-advice magit-diff-visit-directory
      (:around (fn directory &optional other-window) bmg/switch-project-to-worktree)
    "Switch project when DIRECTORY is another worktree of this repository."
    (if (bmg/magit-linked-worktree-p directory)
        (bmg/magit-switch-project-to-worktree directory)
      (funcall fn directory other-window))))

;; No projectile cache invalidation here on purpose: Doom's :tools magit module
;; already runs `+magit-invalidate-projectile-cache-h' from
;; `magit-refresh-buffer-hook', keyed on the status buffer's hash, so it covers
;; every working-tree change -- rebase, pull, reset, external edits -- rather
;; than a hand-picked list of commands, and does it more cheaply.

;;;
;;;
;;; END_code
;;;
;;;

Format on Save

The (format +lsp +onsave) module reformats the whole buffer on every save: with +lsp, apheleia delegates to textDocument/formatting, which has no notion of “only the lines I touched”. In a project whose style does not match the formatter, a one-line edit rewrites the entire file.

So format-on-save is gated: it runs only in projects that have declared a formatter (treefmt.nix, .clang-format, rustfmt.toml, …). Everything else – other people’s repos, stray clones, scratch files – is left alone. Override per project with bmg/format-on-save in .dir-locals.el, or format by hand with +format/region-or-buffer.

;;;
;;;
;;; BEGIN_FormatOnSave
;;;
;;;

;; .editorconfig is deliberately NOT in this list: it declares indentation and
;; line endings, not a formatter, and plenty of hand-formatted projects ship one
;; (e.g. microvm.nix) -- exactly the repos this gate exists to protect.
(defvar bmg/format-on-save-marker-files
  '(".clang-format"
    "treefmt.nix" "treefmt.toml" ".treefmt.toml"
    ".pre-commit-config.yaml"
    "rustfmt.toml" ".rustfmt.toml"
    ".prettierrc" ".prettierrc.json" ".prettierrc.yml" ".prettierrc.yaml"
    ".prettierrc.js" ".prettierrc.mjs" ".prettierrc.cjs"
    "prettier.config.js" ".prettierrc.toml"
    "biome.json" "biome.jsonc"
    "ruff.toml" ".ruff.toml" ".style.yapf"
    ".ocamlformat" ".scalafmt.conf" ".swiftformat" ".stylua.toml")
  "Files whose presence at a project root opts that project into format-on-save.")

(defvar bmg/format-on-save-content-markers
  '(("flake.nix"      . "^[ \t]*formatter[ \t]*=\\|treefmt")
    ("pyproject.toml" . "^\\[tool\\.\\(black\\|ruff\\|yapf\\)"))
  "Alist of (FILENAME . REGEXP) for markers that need a peek inside the file.
A project opts in when FILENAME exists at its root and REGEXP matches.")

(defvar bmg/format-on-save--cache (make-hash-table :test #'equal)
  "Memoisation of project root -> whether that project declares a formatter.")

(defun bmg/format-on-save--content-marker-p (root)
  "Return non-nil if any content marker in ROOT matches its regexp."
  (cl-some
   (lambda (marker)
     (let ((file (expand-file-name (car marker) root)))
       (and (file-readable-p file)
            (with-temp-buffer
              ;; Only the head of the file; these markers always appear early
              ;; and flake.nix can be large.
              (insert-file-contents file nil 0 16384)
              (goto-char (point-min))
              (re-search-forward (cdr marker) nil t)))))
   bmg/format-on-save-content-markers))

(defun bmg/format-on-save-project-p (&optional dir)
  "Return non-nil if the project containing DIR declares a code formatter.
DIR defaults to `default-directory'.  Returns nil outside a project."
  (when-let* ((root (doom-project-root (or dir default-directory))))
    (let ((cached (gethash root bmg/format-on-save--cache 'missing)))
      (if (not (eq cached 'missing))
          cached
        (puthash root
                 ;; Markers are checked at the project root only.  An upward
                 ;; walk (`locate-dominating-file') would eventually find a
                 ;; marker in $HOME and silently enable formatting everywhere.
                 (and (or (cl-some (lambda (f)
                                     (file-exists-p (expand-file-name f root)))
                                   bmg/format-on-save-marker-files)
                          (bmg/format-on-save--content-marker-p root))
                      t)
                 bmg/format-on-save--cache)))))

(defun bmg/format-on-save-reset-cache ()
  "Forget which projects declare a formatter.
Call after adding a formatter config to a project already visited this session."
  (interactive)
  (clrhash bmg/format-on-save--cache)
  (message "format-on-save project cache cleared"))

(defvar bmg/format-on-save 'auto
  "Whether to reformat the buffer on save.
`auto' formats only in projects that declare a formatter (see
`bmg/format-on-save-project-p'); t forces formatting on; nil forces it off.
Set via .dir-locals.el to override for a single project.")
(put 'bmg/format-on-save 'safe-local-variable
     (lambda (v) (memq v '(t nil auto))))

(defun bmg/format-on-save-inhibit-h ()
  "Inhibit format-on-save outside projects that declare a formatter."
  (pcase bmg/format-on-save
    ('t nil)
    ('nil t)
    (_ (not (bmg/format-on-save-project-p)))))

;; Two hooks, because apheleia's extension points differ.
;; `apheleia-inhibit-functions' is consulted *once*, by `apheleia-mode-maybe',
;; to decide whether `apheleia-mode' attaches to a new buffer; it has no effect
;; on buffers already open, so alone it leaves every existing buffer still
;; formatting on save after a config reload.  The advice below covers that gap:
;; `apheleia-format-after-save' is the `after-save-hook' entry point, so gating
;; it is evaluated on *every* save and takes effect immediately in already-open
;; buffers.
;;
;; Deliberately NOT `apheleia-skip-functions': that is checked inside
;; `apheleia-format-buffer' via `apheleia--disallowed-p', which is also on the
;; manual path, so it would break `+format/region-or-buffer' in exactly the
;; projects where hand-formatting is most wanted.
;;
;; `apheleia-inhibit-functions' is autoloaded by apheleia precisely so it can be
;; mutated before the package loads -- no `with-eval-after-load' needed, and
;; Doom's own format module adds to it the same way.  Any function returning
;; non-nil inhibits, so this composes with Doom's `+format-maybe-inhibit-h'
;; rather than replacing it.
(add-hook 'apheleia-inhibit-functions #'bmg/format-on-save-inhibit-h)

(define-advice apheleia-format-after-save
    (:around (fn &rest args) bmg/gate-to-formatter-projects)
  "Skip format-on-save outside projects that declare a formatter.
Only guards the on-save path; explicit `+format/region-or-buffer' still works."
  (unless (bmg/format-on-save-inhibit-h)
    (apply fn args)))

;; Deliberate no-format save.  (C-u C-x C-s already does this: the format module
;; remaps `basic-save-buffer' to `+format/save-buffer', whose prefix arg
;; suppresses apheleia.)
(map! "C-x M-s" #'+format/save-buffer-no-reformat)

;;;
;;;
;;; END_FormatOnSave
;;;
;;;

LLM/ML

Everything that talks to an LLM lives in llm-config.el: copilot for inline completion, gptel over the GitHub Copilot backend, agent-shell for Claude via ACP, and the knowledge-management commands built on top of them.

It is a self-contained unit with its own defgroup and defcustom=s, so it gets its own file for the same reason =elfeed-config.el and remarkable-config.el do, rather than adding ~470 lines here. Keybindings for its commands stay in the Map section below.

(load! "llm-config.el")

Org

;;;
;;;
;;; BEGIN_ORG
;;;
;;;

(defun bmg/switch-to-agenda ()
  "Switch to the custom `o' overview in `org-agenda-custom-commands'."
  (interactive)
  (org-agenda nil "o"))

(defun bmg/archive-all-done ()
  "Archive all DONE tasks in the current buffer to the archive file."
  (interactive)
  (let ((count 0))
    (org-map-entries
     (lambda ()
       (org-archive-subtree)
       (setq count (1+ count))
       (setq org-map-continue-from (org-element-property :begin (org-element-at-point))))
     "/DONE" 'file)
    (message "Archived %d DONE tasks" count)))

;; `org-directory' must be set before org loads.
(setopt org-directory "~/Documents/org/"
       org-archive-location (concat org-directory "/archive.org_archive::datetree/")
       org-id-link-to-org-use-id t
       org-ellipsis ""
       org-src-fontify-natively t
       org-hide-emphasis-markers t
       org-modern-star 'replace)

;; Set AFTER org loads: Doom's +org-init-appearance-h runs on
;; org-load-hook and resets org-startup-folded to nil, silently
;; clobbering a value set at config time.
(with-eval-after-load 'org
  (setopt org-startup-folded 'fold
          org-return-follows-link t))

(setopt org-roam-directory (file-truename (concat org-directory "roam/"))
       org-roam-extract-new-file-path "${slug}.org"
       org-default-notes-file (expand-file-name (format "inbox-%s.org" (system-name)) org-roam-directory)
       ;; defcustom type is (repeat string) -- a bare string makes
       ;; org-noter's dolist crash with wrong-type-argument listp
       org-noter-notes-search-path (list org-roam-directory))

;; Starting a session from the *document* buffer writes NOTER_DOCUMENT relative
;; to the notes file (org-noter.el:279-281), which here means ../../Papers/...
;; while the rest of the vault is ~-relative.  org-noter matches documents with
;; a raw `string=', so a second form for the same PDF reads as a second
;; document.  `file-relative-name' is called exactly once in that function, on
;; that line, so shadowing it is precise.  Same `cl-letf' shape as the
;; citar-org-roam guard below.
(with-eval-after-load 'org-noter
  (define-advice org-noter--create-session-from-document-file-default
      (:around (fn &rest args) bmg/noter-doc-path-abbreviated)
    "Record NOTER_DOCUMENT as a `~'-relative path rather than notes-relative."
    (cl-letf (((symbol-function 'file-relative-name)
               (lambda (filename &optional _dir)
                 (abbreviate-file-name (expand-file-name filename)))))
      (apply fn args))))

(setq +org-capture-todo-file org-default-notes-file
      +org-capture-notes-file org-default-notes-file)

;; org-protocol browser capture (Chrome/Firefox).  Requires the desktop file
;; handler configured in NixOS/home-manager.  "w" prompts and keeps the page
;; selection; "W" is the same capture with `:immediate-finish', i.e. a quick
;; bookmark.
(with-eval-after-load 'org
  (require 'org-protocol)
  (setopt org-protocol-default-template-key "w")
  (add-to-list 'org-capture-templates
               '("w" "Web Capture" entry
                 (file+headline org-default-notes-file "Inbox")
                 "* %?[[%:link][%:description]] :REFILE:web:\n:PROPERTIES:\n:CREATED: %U\n:SOURCE: %:link\n:END:\n%i"
                 :prepend t))
  (add-to-list 'org-capture-templates
               '("W" "Web Capture (quick)" entry
                 (file+headline org-default-notes-file "Inbox")
                 "* [[%:link][%:description]] :REFILE:web:\n:PROPERTIES:\n:CREATED: %U\n:SOURCE: %:link\n:END:\n%i"
                 :prepend t
                 :immediate-finish t))
  ;; Here rather than in `org-roam-capture-templates' because file+headline is
  ;; an org-capture target type, which org-roam templates do not accept.
  (add-to-list 'org-capture-templates
               '("f" "Fleeting note" entry
                 (file+headline org-default-notes-file "Inbox")
                 "* %? :REFILE:\n:PROPERTIES:\n:CREATED: %U\n:END:\n%i"
                 :prepend t)))

;; Capture subdirectories are pre-created here: org-roam v2 has no :mkdir
;; template keyword (the old one in these templates was silently ignored).
(with-eval-after-load 'org-roam
  (dolist (d '("refs" "meetings" "projects"))
    (make-directory (expand-file-name d org-roam-directory) t)))

;; Subdirectories organise, filetags filter -- the two are deliberately
;; independent, so a note's location never constrains how it is queried.
(setq org-roam-capture-templates
       '(;; Default note - flat in roam directory
         ("d" "default" plain "%?"
          :target (file+head "${slug}.org"
                   "#+title: ${title}\n#+created: %u\n#+last_modified: %U\n#+filetags:\n\n")
          :unnarrowed t)

         ;; Literature/reference note
         ("r" "reference" plain "%?"
          :target (file+head "refs/${slug}.org"
                   "#+title: ${title}\n#+created: %u\n#+roam_refs: ${ref}\n#+filetags: :reference:\n\n* Summary\n\n* Notes\n")
          :unnarrowed t)

         ;; Meeting note
         ("m" "meeting" plain "%?"
          :target (file+head "meetings/%<%Y%m%d>-${slug}.org"
                   "#+title: ${title}\n#+date: %<%Y-%m-%d>\n#+created: %u\n#+filetags: :meeting:\n\n* Attendees\n\n* Agenda\n\n* Notes\n\n* Action Items\n")
          :unnarrowed t)))

(setopt org-roam-dailies-directory "daily/")
(with-eval-after-load 'org-roam-dailies
  (make-directory (expand-file-name org-roam-dailies-directory org-roam-directory) t))

(setq org-roam-dailies-capture-templates
       '(("d" "default" entry
          "* %?"
          :target (file+head "%<%Y-%m-%d>.org"
                   "#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"))
         ("t" "task" entry
          "* TODO %?"
          :target (file+head+olp "%<%Y-%m-%d>.org"
                   "#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"
                   ("Tasks")))
         ("j" "journal" entry
          "* %<%H:%M> %?"
          :target (file+head+olp "%<%Y-%m-%d>.org"
                   "#+title: %<%Y-%m-%d %A>\n#+created: %u\n#+filetags: :daily:\n\n"
                   ("Journal")))))

;; Per-project capture: files live under roam/projects/{project-name}/ so org
;; files stay out of shared code repositories.

(defun bmg/get-project-name ()
  "Get current project name or prompt for one.
Uses projectile if in a project, otherwise offers existing project directories."
  (or (and (fboundp 'projectile-project-p)
           (projectile-project-p)
           (projectile-project-name))
      (completing-read "Project: "
                       (when (file-directory-p (expand-file-name "projects" org-roam-directory))
                         (directory-files
                          (expand-file-name "projects" org-roam-directory)
                          nil "^[^.]")))))

(defun bmg/project-org-file (filename)
  "Return path to FILENAME in current project's org directory.
Creates directory and initializes file with proper headers if needed."
  (let* ((project (bmg/get-project-name))
         (dir (expand-file-name (concat "projects/" project) org-roam-directory))
         (file (expand-file-name filename dir)))
    (unless (file-directory-p dir)
      (make-directory dir t))
    (unless (file-exists-p file)
      (with-temp-file file
        (pcase filename
          ("todo.org"
           (insert (format "#+title: %s Tasks\n#+created: %s\n#+filetags: :project:todo:\n\n* Inbox\n\n* Active\n\n* Completed\n"
                           project (format-time-string "[%Y-%m-%d %a]"))))
          ("notes.org"
           (insert (format "#+title: %s Notes\n#+created: %s\n#+filetags: :project:notes:\n\n* Inbox\n\n* Notes\n"
                           project (format-time-string "[%Y-%m-%d %a]"))))
          ("changelog.org"
           (insert (format "#+title: %s Changelog\n#+created: %s\n#+filetags: :project:changelog:\n\n* Unreleased\n\n"
                           project (format-time-string "[%Y-%m-%d %a]"))))
          (_
           (insert (format "#+title: %s - %s\n#+created: %s\n#+filetags: :project:\n\n"
                           project (file-name-sans-extension filename)
                           (format-time-string "[%Y-%m-%d %a]")))))))
    file))

(defun bmg/capture-project-todo ()
  "Capture a TODO for the current project.
Adds to Inbox heading with REFILE tag for GTD processing."
  (interactive)
  (let ((file (bmg/project-org-file "todo.org")))
    (find-file file)
    (goto-char (point-min))
    (re-search-forward "^\\* Inbox" nil t)
    (org-end-of-subtree)
    ;; Land between the two spaces so the typed title keeps a space before
    ;; :REFILE: (8 puts point flush against the tag, and typing destroys it).
    (insert "\n** TODO  :REFILE:")
    (backward-char 9)))

(defun bmg/capture-project-note ()
  "Capture a note for the current project.
Adds to Inbox heading with REFILE tag for GTD processing."
  (interactive)
  (let ((file (bmg/project-org-file "notes.org")))
    (find-file file)
    (goto-char (point-min))
    (re-search-forward "^\\* Inbox" nil t)
    (org-end-of-subtree)
    (insert (format "\n** %s  :REFILE:" (format-time-string "[%Y-%m-%d %a]")))
    (backward-char 9)))

(defun bmg/capture-project-roam-note ()
  "Capture a roam note for the current project.
Creates a new org-roam node in projects/{project-name}/ directory."
  (interactive)
  (let* ((project (bmg/get-project-name))
         (org-roam-capture-templates
          `(("p" "project note" plain "%?"
             :target (file+head
                      ,(format "projects/%s/${slug}.org" project)
                      ,(format "#+title: ${title}\n#+created: %%u\n#+filetags: :project:%s:\n\n" project))
             :unnarrowed t))))
    ;; Per-project dir cannot be pre-created at load time; do it here.
    (make-directory (expand-file-name (format "projects/%s" project)
                                      org-roam-directory)
                    t)
    (org-roam-capture nil "p")))

(defun bmg/open-project-todo ()
  "Open the TODO file for the current project."
  (interactive)
  (find-file (bmg/project-org-file "todo.org")))

(defun bmg/open-project-notes ()
  "Open the notes file for the current project."
  (interactive)
  (find-file (bmg/project-org-file "notes.org")))

;; Redirect Doom's C-c n n → p → t/n/c project capture into roam/projects/.
(define-advice +org-capture-project-todo-file
    (:override () bmg/use-roam-projects)
  "Use roam/projects/{project}/todo.org instead of project-root."
  (bmg/project-org-file "todo.org"))

(define-advice +org-capture-project-notes-file
    (:override () bmg/use-roam-projects)
  "Use roam/projects/{project}/notes.org instead of project-root."
  (bmg/project-org-file "notes.org"))

(define-advice +org-capture-project-changelog-file
    (:override () bmg/use-roam-projects)
  "Use roam/projects/{project}/changelog.org instead of project-root."
  (bmg/project-org-file "changelog.org"))

;; org-mem (https://github.com/meedstrom/org-mem) complements org-roam with fast
;; queries over the roam db -- any property, not just the `org-agenda-files'
;; derivation below.
(use-package org-mem
  :after org
  :config
  (setopt org-mem-watch-dirs (list org-roam-directory)
         ;;org-mem-do-sync-with-org-id t
         org-roam-db-update-on-save nil
         org-mem-roamy-do-overwrite-real-db t)
  ;; `org-mem-roamy-do-try-async' spawns sqlite3 for the DELETE and runs the
  ;; matching INSERT from that process's sentinel, but never clears
  ;; `org-mem-roamy--async-new-rows' after using it.  Two saves close together
  ;; leave two sentinels pending, and the second re-inserts the first's rows:
  (setopt org-mem-roamy-do-try-async nil)
  ;; Cut GC during org-roam operations.  setq, not setopt: the defcustom's
  ;; (int) widget type is broken.
  (setq org-roam-db-gc-threshold most-positive-fixnum)

  (defun bmg/org-mem-set-agenda-files (&rest _)
    "Derive `org-agenda-files' from org-mem: files with TODOs or dates.
Runs on every org-mem full scan, so it uses the raw -int accessors
\(no string formatting/memoization churn) and only sets the
variable when the list actually changed.  The archive filter only
looks at the file NAME -- a note like internet-archive.org must
not be dropped because of its path."
    (let ((files (cl-loop
                  for file in (org-mem-all-files)
                  unless (string-search "archive" (file-name-nondirectory file))
                  when (seq-find (lambda (entry)
                                   (or (org-mem-entry-todo-state entry)
                                       (org-mem-entry-active-timestamps-int entry)
                                       (org-mem-entry-scheduled-int entry)
                                       (org-mem-entry-deadline-int entry)))
                                 (org-mem-entries-in file))
                  collect file)))
      (unless (equal files org-agenda-files)
        (setopt org-agenda-files files))))
  (add-hook 'org-mem-post-full-scan-functions #'bmg/org-mem-set-agenda-files)
  (org-mem-updater-mode)
  (org-mem-roamy-db-mode)
  ;; Doom's +roam module calls org-roam-db-autosync-enable, whose save hook
  ;; conflicts with org-mem-roamy-db-mode: both insert into the same SQLite
  ;; table, causing UNIQUE constraint errors.
  (org-roam-db-autosync-mode -1)

  ;; Doom defers the org-roam DB sync to the session's first `org-roam-db-query'
  ;; via a one-shot :before advice (`+org-roam-try-init-db-a',
  ;; modules/lang/org/contrib/roam.el:39-45) that prints "Initializing org-roam
  ;; database..." and runs a full `org-roam-db-sync'.  `with-eval-after-load'
  ;; because Doom installs the advice when org-roam loads; removing it any
  ;; earlier would be a no-op that Doom then undoes.
  (with-eval-after-load 'org-roam
    (advice-remove 'org-roam-db-query #'+org-roam-try-init-db-a))

  ;; A second, independent trigger for the same full sync: `citar-org-roam-setup'
  ;; opens with an unconditional (org-roam-db-sync) -- citar-org-roam.el:267.
  ;; It runs when `citar-org-activate' lazily loads citar-org-roam during
  ;; font-lock of a [cite:...], so it lands inside `redisplay', mid-typing.
  (with-eval-after-load 'citar-org-roam
    (define-advice citar-org-roam-setup (:around (fn &rest args) bmg/skip-db-sync)
      "Skip the full `org-roam-db-sync' citar-org-roam runs on activation.
org-mem maintains the database; see `org-roam-db-autosync-mode' above."
      (cl-letf (((symbol-function 'org-roam-db-sync) #'ignore))
        (apply fn args))))

  ;; org-mem's DB insert uses `prin1', which preserves Emacs text properties
  ;; (e.g. ws-butler-chg) as #(...) syntax.  EmacSQL's `read-from-string' chokes
  ;; on these, causing "unhandled condition" errors in org-roam-node-list.
  (define-advice org-mem-roamy--mk-literal-input-quoted-like-emacsql
      (:filter-args (args) strip-text-properties)
    (list (mapcar (lambda (row)
                    (mapcar (lambda (val)
                              (if (stringp val)
                                  (substring-no-properties val)
                                val))
                            row))
                  (car args)))))

(setopt org-log-done 'time
       org-log-into-drawer t
       org-log-state-notes-insert-after-drawers nil)

(use-package org-roam-ui
  :after org-roam
  :commands (org-roam-ui-mode org-roam-ui-open)
  :config
  (setopt org-roam-ui-sync-theme t
         org-roam-ui-follow t
         org-roam-ui-update-on-save t
         org-roam-ui-open-on-start nil))

(setopt citar-bibliography (list (concat org-directory "emacs_lit.bib"))
       citar-library-paths '("~/Documents/Papers/")
       citar-notes-paths (list org-roam-directory))

(with-eval-after-load 'citar
  (defvar bmg/my-citar-embark-become-map
    (let ((map (make-sparse-keymap)))
      ;; embark-become only offers this map when the CURRENT minibuffer
      ;; command is bound in it, so the umbrella citar-open must be here
      ;; for the citar->biblio hand-over to activate at all.
      (define-key map (kbd "o") 'citar-open)
      (define-key map (kbd "f") 'citar-open-files)
      (define-key map (kbd "x") 'biblio-arxiv-lookup)
      (define-key map (kbd "c") 'biblio-crossref-lookup)
      (define-key map (kbd "i") 'biblio-ieee-lookup)
      (define-key map (kbd "h") 'biblio-hal-lookup)
      (define-key map (kbd "s") 'biblio-dissemin-lookup)
      (define-key map (kbd "b") 'biblio-dblp-lookup)
      (define-key map (kbd "d") 'biblio-doi-insert-bibtex)
      map)
    "Citar Embark become keymap for biblio lookup."))

(with-eval-after-load 'embark
  (add-to-list 'embark-become-keymaps 'bmg/my-citar-embark-become-map))


;; Tags in a :startgroup are mutually exclusive in fast tag selection, so only
;; genuinely exclusive @contexts go inside; modifier tags (Important, Emacs,
;; uni, PERSONAL) stay outside, or adding one silently removes the @context.
(setopt org-tag-alist '((:startgroup . nil)
                       ("@Project" . ?p)
                       ("@Reading" . ?r)
                       ("@Someday" . ?s)
                       ("@Training" . ?t)
                       ("@Courses" . ?c)
                       ("@Research" . ?R)
                       ("@Issue" . ?i)
                       (:endgroup . nil)
                       ("uni" . ?u)
                       ("Emacs" . ?e)
                       ("Important" . ?I)
                       ("PERSONAL" . ?P)))

(use-package org-super-agenda
  :after org-agenda
  :init
  (setopt
   org-agenda-prefix-format
   '((agenda . " %i %-20:c %?-12t %12s")
     (todo . " %i %-20:c ")
     (tags . " %i %-20:c ")
     (search . " %i %-20:c "))
   org-agenda-todo-keyword-format "%-6s"
   org-agenda-tags-column -120)

  (setopt org-agenda-time-grid '((daily today require-timed)
                                (800 1200 1600 2000)
                                "......"
                                "----------------")
         org-agenda-skip-scheduled-if-done t
         org-agenda-skip-deadline-if-done t
         org-agenda-include-deadlines t
         org-agenda-include-diary nil
         org-agenda-block-separator nil
         org-agenda-compact-blocks t
         org-agenda-start-with-log-mode 'clockcheck
         org-agenda-span 1
         org-agenda-start-day nil) ;; i.e. today

  ;; GTD view: REFILE inbox first; groups match Doom's org-todo-keywords.
  (setq org-agenda-custom-commands
         '(("o" "Overview"
            ((agenda "" ((org-agenda-span 'week)
                         (org-agenda-start-on-weekday 0) ;; Sunday
                         (org-super-agenda-groups
                          '((:name "Today"
                             :time-grid t
                             :date today
                             :scheduled today
                             :order 1)))))
             (alltodo "" ((org-agenda-overriding-header "")
                          ;; Groups match in LIST order (:order only affects
                          ;; display), so selective groups must precede the
                          ;; broad TODO/STRT catch-alls or they never match.
                          (org-super-agenda-groups
                           '(;; Discard first, before any group can claim these.
                             ;; NB: tag match is case-insensitive, so this also
                             ;; drops items inheriting a :daily: filetag.
                             (:discard (:tag ("Chore" "Routine" "Daily")))
                             (:name "📥 Inbox - Process these first"
                              :tag "REFILE"
                              :order 0)
                             (:name "Due Today"
                              :deadline today
                              :order 2)
                             (:name "Overdue"
                              :deadline past
                              :face error
                              :order 7)
                             (:name "Due Soon"
                              :deadline future
                              :order 8)
                             (:name "Important"
                              :tag "Important"
                              :priority "A"
                              :order 6)
                             (:name "Personal"
                              :tag "PERSONAL"
                              :order 12)
                             (:name "Issues"
                              :tag "@Issue"
                              :order 12)
                             (:name "Emacs"
                              :tag "Emacs"
                              :order 13)
                             (:name "Projects"
                              :todo "PROJ"
                              :tag "@Project"
                              :order 14)
                             (:name "Research"
                              :tag "@Research"
                              :order 15)
                             (:name "Training/Courses"
                              :tag ("@Training" "@Courses")
                              :order 16)
                             (:name "To read"
                              :tag "@Reading"
                              :order 30)
                             (:name "Waiting"
                              :todo ("HOLD" "WAIT")
                              :order 20)
                             (:name "University"
                              :tag "uni"
                              :order 32)
                             (:name "Someday"
                              :priority<= "C"
                              :tag "@Someday"
                              :todo "IDEA"
                              :order 90)
                             (:name "Recurring"
                              :todo "LOOP"
                              :order 4)
                             (:name "Ongoing"
                              :todo "STRT"
                              :order 3)
                             (:name "Next to do"
                              :todo "TODO"
                              :order 3)))))))))
  :config
  (org-super-agenda-mode))

(use-package consult-org-roam
  :after org-roam
  :config
  (consult-org-roam-mode 1)
  (setopt consult-org-roam-grep-func #'consult-ripgrep))

;; Corpus search.  One engine, two sets of roots.  Both are consult async
;; commands: results narrow as you type, text hits preview live, and
;; `C-c C-;' exports to the `grep-mode' buffer these used to produce outright.

(defun bmg/kb--search-roots ()
  "Roots for `bmg/search-knowledge-base': notes, bibliography, documents.
The trailing nil makes `append' copy its last argument, so `delq' cannot
splice `bmg/document-search-roots'."
  (delq nil (append (list (bound-and-true-p org-roam-directory))
                    (bound-and-true-p citar-bibliography)
                    bmg/document-search-roots
                    nil)))

(defun bmg/search-knowledge-base (&optional paths initial)
  "Search org-roam notes, papers and the bibliography in one pass.
Async consult search: text hits preview live and jump to the exact line.
`C-c C-;' exports to a `grep-mode' buffer, `C-c C-e' a wgrep-editable one.

The minibuffer is prefilled with `#': `neural#2024' sends `neural' to rga
and filters `2024' in Emacs.  The filter only sees what rga returned, and
rga caps at 5 hits per file, so raise the cap before narrowing when a
search must be exhaustive.

Bare flags after the query go to rga -- `neural --glob *.pdf',
`neural --max-count=200', `neural -i'.  A literal `--' ends the option
section instead, returning the rest to the search string; `\\-' escapes a
single dashed word.  A prefix argument chooses the roots.

RET opens a PDF at the matching page; EPUB opens at the top.  Neither
auto-previews; `C-SPC' does, but only under `consult-preview-partial-size'
(1MB) -- larger files abort on NUL bytes.  PATHS and INITIAL are as for
`bmg/consult--rga'."
  (interactive "P")
  (bmg/consult--rga "Knowledge base"
                    (if (or (consp paths) (stringp paths)) paths (bmg/kb--search-roots))
                    initial))

(defun bmg/rga-search (&optional paths initial)
  "Search PDFs, EPUBs and other documents with ripgrep-all.
`bmg/search-knowledge-base' over `bmg/document-search-roots' alone -- same
engine, narrower roots, so rga touches fewer files.  See that command for
the input syntax.  PATHS and INITIAL are as there."
  (interactive "P")
  (bmg/consult--rga "Documents"
                    (if (or (consp paths) (stringp paths)) paths bmg/document-search-roots)
                    initial))

(defun bmg/org-roam-review-week ()
  "Open all daily notes from the past week for review."
  (interactive)
  (require 'org-roam-dailies)
  (let ((files '()))
    (dotimes (i 7)
      (let* ((date (time-subtract (current-time) (days-to-time i)))
             ;; No org-roam-dailies--daily-note-path in org-roam;
             ;; mirror the dailies template's "%<%Y-%m-%d>.org" target.
             (file (expand-file-name
                    (format-time-string "%Y-%m-%d.org" date)
                    (expand-file-name org-roam-dailies-directory
                                      org-roam-directory))))
        (when (file-exists-p file)
          (push file files))))
    (if files
        (progn
          (dolist (file (reverse files))
            (find-file-other-window file))
          (message "Opened %d daily notes from the past week" (length files)))
      (message "No daily notes found for the past week"))))

;;;
;;;
;;; END_ORG
;;;
;;;

Navigation

Doom ships avy, but binds it only under evil’s gs prefix, so with evil disabled there is no on-screen jumping at all. The rest of this section turns on motion behaviour Emacs has but leaves off by default: key-repeating for motion commands, a walkable mark ring, and an isearch that counts matches and can be navigated without exiting.

The destination-first commands live under a jump prefix on the leader (C-c j), with the ones worth a single chord also on M-j and the M-g goto map.

Why C-c c j needed a wrapper

consult-lsp-symbols fires workspace/symbol from minibuffer-setup. Servers that do not implement it – nixd and harper-ls among them – make lsp-mode signal there, and the error surfaces as a misleading Wrong type argument: overlayp, nil: consult’s async indicator never reached its own setup, so its overlay was still nil when teardown ran delete-overlay. The real message is lost, because the cleanup error is signalled last.

textDocument/documentSymbol is far more widely implemented (nixd has it), so the key falls back to consult-imenu rather than failing.

;;;
;;;
;;; BEGIN_Navigation
;;;
;;;

;; Repeats motion on the last key alone, for commands carrying a `repeat-map'
;; property: M-g n n n, C-x [ [ [, C-x <left> <left>.  Not C-x o -- that is
;; `ace-window', and only `other-window' has the repeat map.
(repeat-mode 1)

;; smartparens ships no repeat-map, so the C-M- sexp motions repeat only via
;; this one.  `:repeat t' sets the property on every command in the map.
(with-eval-after-load 'smartparens
  (defvar-keymap bmg/sexp-repeat-map
    :repeat t
    "f" #'sp-forward-sexp
    "b" #'sp-backward-sexp
    "n" #'sp-next-sexp
    "p" #'sp-previous-sexp
    "u" #'sp-up-sexp
    "d" #'sp-down-sexp
    "a" #'sp-beginning-of-sexp
    "e" #'sp-end-of-sexp))

;; C-u C-SPC then bare C-SPC keeps walking back through `mark-ring'.
(setopt set-mark-command-repeat-pop t)

;; `isearch-allow-motion' rebinds M-< M-> C-v M-v inside a search to jump
;; between matches instead of exiting it.
(setopt isearch-lazy-count t
        isearch-allow-motion t)

;; `default-indent-new-line' keeps its identical C-M-j binding.
(map! "M-j" #'avy-goto-char-timer)

(with-eval-after-load 'avy
  (setopt avy-all-windows t
          avy-timeout-seconds 0.4))

(map! "M-g a" #'avy-goto-line
      "M-g o" #'consult-outline
      "M-g m" #'consult-mark
      "M-g M" #'consult-global-mark)

;; `lsp--find-workspaces-for' is the same private gate `lsp--send-request-async'
;; applies before signalling, so this predicate cannot disagree with it.
(defun bmg/lsp-workspace-symbols-available-p ()
  "Return non-nil if an attached server implements `workspace/symbol'."
  (and (bound-and-true-p lsp-mode)
       (fboundp 'lsp--find-workspaces-for)
       (lsp--find-workspaces-for "workspace/symbol")))

(defun bmg/lsp-symbols-dwim ()
  "Jump to a symbol, workspace-wide when the server supports it.
Falls back to `consult-imenu', which needs only
`textDocument/documentSymbol'."
  (interactive)
  (call-interactively
   (if (bmg/lsp-workspace-symbols-available-p)
       #'consult-lsp-symbols
     #'consult-imenu)))

;; Bare :prefix "c" -- re-labelling Doom's would wipe every binding under it.
(map! :leader
      (:prefix "c"
       :desc "Jump to symbol" "j" #'bmg/lsp-symbols-dwim))

;; The leader IS C-c, so a global C-c j binding would be shadowed by
;; `doom-leader-map'.  New prefix, so :prefix-map is safe here.
(map! :leader
      (:prefix-map ("j" . "jump")
       :desc "Char (timer)"      "j" #'avy-goto-char-timer
       :desc "Line"              "l" #'avy-goto-line
       :desc "Word"              "w" #'avy-goto-word-1
       :desc "Symbol"            "s" #'avy-goto-symbol-1
       :desc "Visible link"      "L" #'link-hint-open-link
       :desc "Outline heading"   "o" #'consult-outline
       :desc "Symbol in buffer"  "i" #'consult-imenu
       :desc "Symbol in project" "I" #'consult-imenu-multi
       :desc "Mark ring"         "m" #'consult-mark
       :desc "Global mark ring"  "M" #'consult-global-mark))

;;;
;;;
;;; END_Navigation
;;;
;;;

Map

;;;
;;;
;;; BEGIN_Map
;;;
;;;

;; C-. follows the path at point (ffap), mirroring M-. for definitions.
;; Doom's C-c s f stays bound; this is an additional key, not a move.
(map! "C-." #'+lookup/file)

;; Swaps Doom's M-? with the global M-/ `dabbrev-expand'.
(map! "M-/" #'+lookup/references
      "M-?" #'dabbrev-expand)

;; nixd never answers textDocument/formatting for documents over ~8 KiB (it
;; deadlocks piping to nixfmt), so format-on-save hung on every larger file.
;; Setting `+format-with' stops `+format-with-lsp-toggle-h' claiming the buffer,
;; leaving apheleia's own nixfmt entry in charge.  "-" avoids nixfmt 1.4's
;; deprecation warning on stderr.
(with-eval-after-load 'apheleia
  (setf (alist-get 'nixfmt apheleia-formatters) '("nixfmt" "-")))

(add-hook 'nix-ts-mode-hook
          (defun bmg/nix-format-with-nixfmt ()
            (setq-local +format-with 'nixfmt)))

;; uv-mode is a global minor mode whose keymap outranks git-commit's, so once a
;; Python file loads it, C-c C-s runs uv-mode-set everywhere instead of signing
;; off a commit.  Both commands remain on M-x.
(with-eval-after-load 'uv-mode
  (map! :map uv-mode-map
        "C-c C-s" nil
        "C-c C-u" nil))

;; Doom installs and configures vundo but binds no key to it.  The two keys
;; reached for when going a long way back get the tree; C-_ and M-_ stay
;; linear undo-fu.  These are plain global bindings, which beat Doom's
;; `[remap undo]' in `undo-fu-mode-map' -- the remap only fires when the key
;; still resolves to `undo'.
(map! "C-x u" #'vundo
      "C-/"   #'vundo)

;; Doom's spell module remapped `ispell-word' to +spell/correct and owned
;; <leader> t s; both are vacated with the module off, so jinx takes them.
(map! [remap ispell-word] #'jinx-correct)
(map! :leader
      (:prefix "t"
       :desc "Spell checker" "s" #'jinx-mode))

;; SPC z for org-roam -- shorter than Doom's M-SPC m m / C-c l m.
(with-eval-after-load 'org-roam
  (map! :leader
        (:prefix-map ("z" . "org-roam")
         "c" #'org-roam-capture
         "D" #'org-roam-demote-entire-buffer
         "f" #'consult-org-roam-file-find      ; Enhanced with preview
         "F" #'org-roam-ref-find
         "g" #'org-roam-graph
         "i" #'org-roam-node-insert
         "I" #'org-id-get-create
         "t" #'org-roam-buffer-toggle
         "T" #'org-roam-buffer-display-dedicated
         "r" #'org-roam-refile
         "R" #'bmg/find-related-notes          ; AI: Find related notes
         "s" #'consult-org-roam-search         ; Full-text search
         "S" #'bmg/suggest-tags-for-buffer     ; AI: Suggest tags
         "b" #'consult-org-roam-backlinks      ; Interactive backlinks
         "l" #'consult-org-roam-forward-links  ; Forward links
         "w" #'bmg/generate-weekly-review      ; AI: Weekly review
         (:prefix ("a" . "AI/analysis")
          :desc "Suggest tags"        "t" #'bmg/suggest-tags-for-buffer
          :desc "Summarize paper"     "s" #'bmg/summarize-paper
          :desc "Find related"        "r" #'bmg/find-related-notes
          :desc "Weekly review"       "w" #'bmg/generate-weekly-review
          :desc "Check tags"          "c" #'bmg/check-tag-consistency
          :desc "Find orphans"        "o" #'bmg/find-orphan-notes)
         (:prefix ("d" . "dailies")
          "b" #'org-roam-dailies-goto-previous-note
          "d" #'org-roam-dailies-goto-date
          "D" #'org-roam-dailies-capture-date
          "f" #'org-roam-dailies-goto-next-note
          "m" #'org-roam-dailies-goto-tomorrow
          "M" #'org-roam-dailies-capture-tomorrow
          "n" #'org-roam-dailies-capture-today
          "t" #'org-roam-dailies-goto-today
          "T" #'org-roam-dailies-capture-today
          "y" #'org-roam-dailies-goto-yesterday
          "Y" #'org-roam-dailies-capture-yesterday
          "-" #'org-roam-dailies-find-directory)
         (:prefix ("o" . "node properties")
          "a" #'org-roam-alias-add
          "A" #'org-roam-alias-remove
          "t" #'org-roam-tag-add
          "T" #'org-roam-tag-remove
          "r" #'org-roam-ref-add
          "R" #'org-roam-ref-remove)
         (:prefix ("p" . "project")
          :desc "Project todo"      "t" #'bmg/capture-project-todo
          :desc "Project note"      "n" #'bmg/capture-project-note
          :desc "Project roam note" "p" #'bmg/capture-project-roam-note
          :desc "Open project todo" "T" #'bmg/open-project-todo
          :desc "Open project notes" "N" #'bmg/open-project-notes)
         (:prefix ("u" . "UI")
          :desc "Open graph"        "u" #'org-roam-ui-open
          :desc "Toggle UI mode"    "m" #'org-roam-ui-mode))))

(map!
 (;;: org-agenda
  (:leader
        ;;; <leader> n --- notes
   (:prefix "n"
    :desc "Org agenda"  "a" #'bmg/switch-to-agenda))

  (:map org-agenda-mode-map
        "i"                       #'org-agenda-clock-in
        "R"                       #'org-agenda-refile))

 (;;: open submenu
  (:leader
        ;;; <leader> o --- open
   (:prefix "o"
    :desc "Url"  "u" #'browse-url)))

 (;;: quit -- bare :prefix, never a re-labelled one (that wipes the map)
  (:leader
   (:prefix "q"
    :desc "Kill Emacs (confirm)" "Q" #'bmg/kill-emacs-confirm)))

 (;;: crux and stuff
  (:leader
        ;;;  <leader> b --- prelude
   (:prefix-map ("b" . "prelude")
    :desc "crux-cleanup-buffer-or-region"          "c" #'crux-cleanup-buffer-or-region
    :desc "crux-duplicate-current-line-or-region"  "d" #'crux-duplicate-current-line-or-region
    :desc "crux-delete-file-and-buffer"            "D" #'crux-delete-file-and-buffer
    :desc "crux-kill-other-buffers"                "k" #'crux-kill-other-buffers
    :desc "crux-open-with"                         "o" #'crux-open-with
    ;; the canonical autoloaded command; the -buffer-and-file alias is
    ;; void until crux happens to load
    :desc "crux-rename-file-and-buffer"            "r" #'crux-rename-file-and-buffer
    :desc "crux-transpose-windows"                 "s" #'crux-transpose-windows
    :desc "crux-view-url"                          "u" #'crux-view-url
    :desc "crux-indent-defun"                      "TAB" #'crux-indent-defun
    :desc "Elfeed RSS enter"                       "e" #'=rss))

  (:leader
   (:prefix "s"
    :desc "Search papers (rga)"                    "P" #'bmg/rga-search
    :desc "Ask knowledge base"                     "Q" #'bmg/ask-knowledge-base
    :desc "Search knowledge base"                  "k" #'bmg/search-knowledge-base)))

 (;;: grammar -- LanguageTool runs as a server (services.languagetool in
  ;;: ~/.dotfiles), reached over langtool-http-server-port.  New prefix, so
  ;;: :prefix-map is safe here.
  (:leader
   (:prefix-map ("g" . "grammar")
    :desc "Check buffer"        "c" #'langtool-check
    :desc "Clear results"       "d" #'langtool-check-done
    :desc "Correct buffer"      "f" #'langtool-correct-buffer
    :desc "Correct at point"    "." #'langtool-correct-at-point
    :desc "Message at point"    "m" #'langtool-show-message-at-point
    :desc "Next error"          "n" #'langtool-goto-next-error
    :desc "Previous error"      "p" #'langtool-goto-previous-error
    :desc "Toggle writegood"    "w" #'writegood-mode)))
 ) ;; END MAP

;; Localleader bindings for org-mode AI functions (C-c l in org-mode)
(with-eval-after-load 'org
  (map! :map org-mode-map
        :localleader
        ;; "B" not "P": localleader P is Doom's org-publish prefix
        ;; (org-publish-all & friends) and must stay reachable.
        :desc "Process inbox item" "B" #'bmg/process-inbox-item
        :desc "Summarize paper"    "S" #'bmg/summarize-paper))

;; AI prefix on the leader (C-c A / M-SPC A).  NOT (global-set-key (kbd "C-c a")
;; ...): Doom's leader map wins for C-c sequences and already binds "a" to
;; embark-act, which fully shadowed the global binding.
(defvar bmg/ai-command-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "t") #'bmg/suggest-tags-for-buffer)
    (define-key map (kbd "s") #'bmg/summarize-paper)
    (define-key map (kbd "p") #'bmg/process-inbox-item)
    (define-key map (kbd "r") #'bmg/find-related-notes)
    (define-key map (kbd "q") #'bmg/ask-knowledge-base)
    (define-key map (kbd "k") #'bmg/search-knowledge-base)
    (define-key map (kbd "w") #'bmg/generate-weekly-review)
    (define-key map (kbd "c") #'bmg/check-tag-consistency)
    (define-key map (kbd "o") #'bmg/find-orphan-notes)
    map)
  "Keymap for AI-powered knowledge management commands.")

(define-key doom-leader-map (kbd "A") (cons "AI" bmg/ai-command-map))


(map! :map dirvish-mode-map
      "<mouse-1>" #'dirvish-subtree-toggle-or-open
      "<mouse-2>" #'dired-mouse-find-file-other-window
      "<mouse-3>" #'dired-mouse-find-file
      "?"   #'dirvish-dispatch
      "q"   #'dirvish-quit
      "b"   #'dirvish-quick-access
      "f"   #'dirvish-file-info-menu
      "S"   #'dirvish-quicksort
      "F"   #'dirvish-layout-toggle
      "z"   #'dirvish-history-jump
      "TAB" #'dirvish-subtree-toggle
      "M-b" #'dirvish-history-go-backward
      "M-f" #'dirvish-history-go-forward
      "M-n" #'dirvish-narrow
      "M-m" #'dirvish-mark-menu
      "M-s" #'dirvish-setup-menu
      "M-e" #'dirvish-emerge-menu
      ;; dirvish-yank lives under the y-prefix; a top-level "p" would
      ;; shadow dired-previous-line ("P" would shadow dired-do-print).
      (:prefix ("y" . "yank")
               "l"   #'dirvish-copy-file-true-path
               "n"   #'dirvish-copy-file-name
               "p"   #'dirvish-copy-file-path
               "P"   #'dirvish-yank
               "r"   #'dirvish-copy-remote-path
               "y"   #'dired-do-copy)
      (:prefix ("s" . "symlinks")
               "s"   #'dirvish-symlink
               "S"   #'dirvish-relative-symlink
               "h"   #'dirvish-hardlink))

;;;
;;;
;;; END_Map
;;;
;;;

Coach

Watches which commands run and, when a slower route is used where a faster one exists, says so once. The implementation lives in its own file, coach.el, for the same reason elfeed-config.el and remarkable-config.el do: the rule set grows, and it should not swell the literate config.

The recorder is a post-command-hook that pushes one entry and returns (measured at 0.22 µs/call); every detector runs on an idle timer instead, so typing is never slowed and nudges land at a pause rather than mid-burst.

;;;
;;;
;;; BEGIN_Coach
;;;
;;;

(load! "coach.el")
(coach-mode 1)

;;;
;;;
;;; END_Coach
;;;
;;;

Elfeed Enhancements

;;;
;;;
;;; BEGIN_Elfeed_Enhancements
;;;
;;;

(defun bmg/elfeed-mark-all-as-read ()
  "Mark all entries in current elfeed filter as read."
  (interactive)
  (when (yes-or-no-p "Mark all visible entries as read? ")
    (let ((count 0))
      (save-excursion
        (goto-char (point-min))
        (while (not (eobp))
          (when-let* ((entry (elfeed-search-selected :ignore-region)))
            (elfeed-untag entry 'unread)
            (setq count (1+ count)))
          (forward-line)))
      (elfeed-search-update--force)
      (message "Marked %d entries as read" count))))

;; arXiv capture: `C' and `a' both run bmg/elfeed-arxiv-intake
;; (elfeed-config.el), which fetches PDF + BibTeX and creates the roam note in
;; one pass.  The old separate bmg/elfeed-capture-arxiv spliced feed-controlled
;; text into an org-capture template, which would execute %(elisp) from a
;; malicious feed -- do not reintroduce that pattern.
(with-eval-after-load 'elfeed
  (map! :map elfeed-search-mode-map
        "R" #'bmg/elfeed-mark-all-as-read)
  (map! :map elfeed-show-mode-map
        "C" #'bmg/elfeed-arxiv-intake))

;;;
;;;
;;; END_Elfeed_Enhancements
;;;
;;;

reMarkable Integration

Integration between reMarkable Paper Pro tablet and Emacs org-mode. Uses USB Web Interface (http://10.11.99.1) - NO developer mode required!

Setup:

  1. On reMarkable: Settings → Storage → Enable “USB web interface”
  2. Connect device via USB cable
  3. Test: C-c r t (remarkable-test-connection)

The implementation lives in its own file, remarkable-config.el, rather than inline. It is a self-contained pseudo-package (its own defgroup and remarkable- prefix), and it generates a lot of org text: keeping it in a .el file removes the tangling hazard where a column-0 * inside a template string would silently terminate a src block (which previously dropped six functions).

;; Loaded here (end of config) so citar-library-paths and doom-leader-map,
;; referenced at load time, are already set.
(load! "remarkable-config.el")