Skip to content

The modules of my Emacs configuration (`jw-emacs-modules/`)

;;; jw-emacs-theme.el --- Theme setup and related -*- lexical-binding: t; -*-
;;;; Load the desired theme module
;; These all reference my packages: `modus-themes', `ef-themes',
;; `standard-themes'.
(when jw-emacs-load-theme-family
(require
(pcase jw-emacs-load-theme-family
('ef 'jw-emacs-ef-themes)
('modus 'jw-emacs-modus-themes)
('standard 'jw-emacs-standard-themes))))
;;; jw-emacs-modus-themes.el --- The Modus themes -*- lexical-binding: t; -*-
;; The themes are highly customisable. Read the manual:
;; <https://protesilaos.com/emacs/modus-themes>.
(use-package modus-themes
:straight t
:demand t
:bind (("<f5>" . modus-themes-toggle)
("C-<f5>" . modus-themes-select))
:config
(setq modus-themes-custom-auto-reload nil
modus-themes-to-toggle '(modus-operandi modus-vivendi)
;; modus-themes-to-toggle '(modus-operandi-tinted modus-vivendi-tinted)
;; modus-themes-to-toggle '(modus-operandi-deuteranopia modus-vivendi-deuteranopia)
;; modus-themes-to-toggle '(modus-operandi-tritanopia modus-vivendi-tritanopia)
modus-themes-mixed-fonts t
modus-themes-variable-pitch-ui t
modus-themes-italic-constructs t
modus-themes-bold-constructs nil
modus-themes-completions '((t . (extrabold)))
modus-themes-prompts '(extrabold)
modus-themes-headings
'((agenda-structure . (variable-pitch light 2.2))
(agenda-date . (variable-pitch regular 1.3))
(t . (regular 1.15))))
(setq modus-themes-common-palette-overrides nil))
(if (jw-emacs-theme-environment-dark-p)
(modus-themes-load-theme (cadr modus-themes-to-toggle))
(modus-themes-load-theme (car modus-themes-to-toggle)))
(provide 'jw-emacs-modus-themes)
;;; jw-emacs-ef-themes.el --- The Ef (εὖ) themes -*- lexical-binding: t; -*-
;; The themes are customisable. Read the manual:
;; <https://protesilaos.com/emacs/ef-themes>.
(use-package ef-themes
:straight t
:demand t
:bind ("<f5>" . ef-themes-select)
:config
(setq ef-themes-variable-pitch-ui t
ef-themes-mixed-fonts t
ef-themes-headings ; read the manual's entry of the doc string
'((0 . (variable-pitch light 1.9))
(1 . (variable-pitch light 1.8))
(2 . (variable-pitch regular 1.7))
(3 . (variable-pitch regular 1.6))
(4 . (variable-pitch regular 1.5))
(5 . (variable-pitch 1.4)) ; absence of weight means `bold'
(6 . (variable-pitch 1.3))
(7 . (variable-pitch 1.2))
(agenda-date . (semilight 1.5))
(agenda-structure . (variable-pitch light 1.9))
(t . (variable-pitch 1.1))))
;; The `ef-themes' provide lots of themes. I want to pick one at
;; random when I start Emacs. Newer ef-themes replaced the old
;; `(ef-themes-load-random 'light|'dark)' Lisp call with the dedicated,
;; zero-argument commands `ef-themes-load-random-light' and
;; `ef-themes-load-random-dark'. I just check with my desktop
;; environment to determine which variant to pick. Those functions are
;; in my init.el.
(if (jw-emacs-theme-environment-dark-p)
(ef-themes-load-random-dark)
(ef-themes-load-random-light)))
(provide 'jw-emacs-ef-themes)
;;;; Pulsar
;; Read the pulsar manual: <https://protesilaos.com/emacs/pulsar>.
(use-package pulsar
:straight t
:config
(setopt pulsar-pulse t
pulsar-delay 0.055
pulsar-iterations 10
pulsar-face 'pulsar-magenta
pulsar-highlight-face 'pulsar-cyan)
(pulsar-global-mode 1)
:hook
;; There are convenience functions/commands which pulse the line using
;; a specific colour: `pulsar-pulse-line-red' is one of them.
((next-error . (pulsar-pulse-line-red pulsar-recenter-top pulsar-reveal-entry))
(minibuffer-setup . pulsar-pulse-line-red))
:bind
;; pulsar does not define any key bindings. This is just my personal
;; preference. Remember to read the manual on the matter. Evaluate:
;;
;; (info "(elisp) Key Binding Conventions")
(("C-x l" . pulsar-pulse-line) ; override `count-lines-page'
("C-x L" . pulsar-highlight-dwim))) ; or use `pulsar-highlight-line'
;;;; Lin
;; Read the lin manual: <https://protesilaos.com/emacs/lin>.
(use-package lin
:straight t
:hook (after-init . lin-global-mode) ; applies to all `lin-mode-hooks'
:config
;; You can use this to live update the face:
;;
;; (customize-set-variable 'lin-face 'lin-green)
;;
;; Or `setopt' on Emacs 29: (setopt lin-face 'lin-yellow)
;;
;; I still prefer `setq' for consistency.
(setq lin-face 'lin-magenta))
;;;; Increase padding of windows/frames
;; Yet another one of my packages:
;; <https://protesilaos.com/codelog/2023-06-03-emacs-spacious-padding/>.
(use-package spacious-padding
:straight t
:if (display-graphic-p)
:hook (after-init . spacious-padding-mode)
:bind ("<f8>" . spacious-padding-mode)
:init
;; These are the defaults, but I keep it here for visiibility.
(setq spacious-padding-widths
'( :internal-border-width 15
:header-line-width 4
:mode-line-width 6
:tab-width 4
:right-divider-width 1
:scroll-bar-width 8
:left-fringe-width 20
:right-fringe-width 20))
;; Read the doc string of `spacious-padding-subtle-mode-line' as
;; it is very flexible.
(setq spacious-padding-subtle-mode-line
`( :mode-line-active ,(if (or (eq jw-emacs-load-theme-family 'modus)
(eq jw-emacs-load-theme-family 'standard))
'default
'help-key-binding)
:mode-line-inactive window-divider)))
;;; Cursor appearance (cursory)
;; Read the manual: <https://protesilaos.com/emacs/cursory>.
(use-package cursory
:straight t
:demand t
:if (display-graphic-p)
:config
(setq cursory-presets
'((box
:blink-cursor-interval 1.2)
(box-no-blink
:blink-cursor-mode -1)
(bar
:cursor-type (bar . 2)
:blink-cursor-interval 0.8)
(bar-no-other-window
:inherit bar
:cursor-in-non-selected-windows nil)
(bar-no-blink
:cursor-type (bar . 2)
:blink-cursor-mode -1)
(underscore
:cursor-type (hbar . 3)
:blink-cursor-blinks 50)
(underscore-thin-other-window
:inherit underscore
:cursor-in-non-selected-windows (hbar . 1))
(underscore-thick
:cursor-type (hbar . 8)
:blink-cursor-interval 0.3
:blink-cursor-blinks 50
:cursor-in-non-selected-windows (hbar . 3))
(underscore-thick-no-blink
:blink-cursor-mode -1
:cursor-type (hbar . 8)
:cursor-in-non-selected-windows (hbar . 3))
(t ; the default values
:cursor-type box
:cursor-in-non-selected-windows hollow
:blink-cursor-mode 1
:blink-cursor-blinks 10
:blink-cursor-interval 0.2
:blink-cursor-delay 0.2)))
;; I am using the default values of `cursory-latest-state-file'.
;; Set last preset or fall back to desired style from `cursory-presets'.
(cursory-set-preset (or (cursory-restore-latest-preset) 'box))
:hook
;; The other side of `cursory-restore-latest-preset'.
(kill-emacs . cursory-store-latest-preset)
:bind
;; We have to use the "point" mnemonic, because C-c c is often the
;; suggested binding for `org-capture' and is the one I use as well.
("C-c p" . cursory-set-preset))
;;;; Theme buffet
(use-package theme-buffet
:straight t
:after (:any modus-themes ef-themes)
:defer 1
:config
(let ((modus-themes-p (featurep 'modus-themes))
(ef-themes-p (featurep 'ef-themes)))
(setq theme-buffet-menu 'end-user)
(setq theme-buffet-end-user
(cond
((and modus-themes-p ef-themes-p)
'( :night (modus-vivendi ef-dark ef-winter ef-autumn ef-night ef-duo-dark ef-symbiosis)
:morning (modus-operandi ef-light ef-cyprus ef-spring ef-frost ef-duo-light)
:afternoon (modus-operandi-tinted ef-arbutus ef-day ef-kassio ef-summer ef-elea-light ef-maris-light ef-melissa-light ef-trio-light ef-reverie)
:evening (modus-vivendi-tinted ef-rosa ef-elea-dark ef-maris-dark ef-melissa-dark ef-trio-dark ef-dream)))
(ef-themes-p
'( :night (ef-dark ef-winter ef-autumn ef-night ef-duo-dark ef-symbiosis)
:morning (ef-light ef-cyprus ef-spring ef-frost ef-duo-light)
:afternoon (ef-arbutus ef-day ef-kassio ef-summer ef-elea-light ef-maris-light ef-melissa-light ef-trio-light ef-reverie)
:evening (ef-rosa ef-elea-dark ef-maris-dark ef-melissa-dark ef-trio-dark ef-dream)))
(modus-themes-p
'( :night (modus-vivendi modus-vivendi-tinted modus-vivendi-tritanopia modus-vivendi-deuteranopia)
:morning (modus-operandi modus-operandi-tinted modus-operandi-tritanopia modus-operandi-deuteranopia)
:afternoon (modus-operandi modus-operandi-tinted modus-operandi-tritanopia modus-operandi-deuteranopia)
:evening (modus-vivendi modus-vivendi-tinted modus-vivendi-tritanopia modus-vivendi-deuteranopia)))))
;; Theme selection stays fixed after startup. Use the theme commands
;; explicitly when a change is wanted.
))
;;;; Fontaine (font configurations)
;; Read the manual: <https://protesilaos.com/emacs/fontaine>
(use-package fontaine
:straight t
:if (display-graphic-p)
:hook
;; Persist the latest font preset when closing/starting Emacs.
((after-init . fontaine-mode)
(after-init . (lambda ()
;; Set last preset or fall back to desired style from `fontaine-presets'.
(fontaine-set-preset (or (fontaine-restore-latest-preset) 'regular)))))
:bind (("C-c f" . fontaine-set-preset)
("C-c F" . fontaine-toggle-preset))
:config
;; And this is for Emacs28.
(setq-default text-scale-remap-header-line t)
;; This is the default value. Just including it here for
;; completeness.
(setq fontaine-latest-state-file (locate-user-emacs-file "fontaine-latest-state.eld"))
;; The font family is my design: <https://github.com/protesilaos/aporetic>.
(setq fontaine-presets
'((small
:default-height 80)
(regular) ; like this it uses all the fallback values and is named `regular'
(medium
:default-family "Aporetic Serif Mono"
:default-height 115
:fixed-pitch-family "Aporetic Serif Mono"
:variable-pitch-family "Aporetic Sans")
(large
:default-height 150)
(presentation
:default-height 180)
(jumbo
:inherit medium
:default-height 260)
(t
;; I keep all properties for didactic purposes, but most can be
;; omitted. See the fontaine manual for the technicalities:
;; <https://protesilaos.com/emacs/fontaine>.
:default-family "Aporetic Sans Mono"
:default-weight regular
:default-slant normal
:default-width normal
:default-height 100
:fixed-pitch-family "Aporetic Sans Mono"
:fixed-pitch-weight nil
:fixed-pitch-slant nil
:fixed-pitch-width nil
:fixed-pitch-height 1.0
:fixed-pitch-serif-family nil
:fixed-pitch-serif-weight nil
:fixed-pitch-serif-slant nil
:fixed-pitch-serif-width nil
:fixed-pitch-serif-height 1.0
:variable-pitch-family "Aporetic Serif"
:variable-pitch-weight nil
:variable-pitch-slant nil
:variable-pitch-width nil
:variable-pitch-height 1.0
:mode-line-active-family nil
:mode-line-active-weight nil
:mode-line-active-slant nil
:mode-line-active-width nil
:mode-line-active-height 1.0
:mode-line-inactive-family nil
:mode-line-inactive-weight nil
:mode-line-inactive-slant nil
:mode-line-inactive-width nil
:mode-line-inactive-height 1.0
:header-line-family nil
:header-line-weight nil
:header-line-slant nil
:header-line-width nil
:header-line-height 1.0
:line-number-family nil
:line-number-weight nil
:line-number-slant nil
:line-number-width nil
:line-number-height 1.0
:tab-bar-family nil
:tab-bar-weight nil
:tab-bar-slant nil
:tab-bar-width nil
:tab-bar-height 1.0
:tab-line-family nil
:tab-line-weight nil
:tab-line-slant nil
:tab-line-width nil
:tab-line-height 1.0
:bold-family nil
:bold-slant nil
:bold-weight bold
:bold-width nil
:bold-height 1.0
:italic-family nil
:italic-weight nil
:italic-slant italic
:italic-width nil
:italic-height 1.0
:line-spacing nil)))
(with-eval-after-load 'pulsar
(add-hook 'fontaine-set-preset-hook #'pulsar-pulse-line)))
;;;;; `variable-pitch-mode' setup
(use-package face-remap
:straight nil
:functions jw/enable-variable-pitch
:bind ( :map ctl-x-x-map
("v" . variable-pitch-mode))
:hook ((text-mode notmuch-show-mode elfeed-show-mode) . jw/enable-variable-pitch)
:config
;; NOTE 2022-11-20: This may not cover every case, though it works
;; fine in my workflow. I am still undecided by EWW.
(defun jw/enable-variable-pitch ()
(unless (derived-mode-p 'mhtml-mode 'nxml-mode 'yaml-mode)
(variable-pitch-mode 1)))
;;;;; Resize keys with global effect
:bind
;; Emacs 29 introduces commands that resize the font across all
;; buffers (including the minibuffer), which is what I want, as
;; opposed to doing it only in the current buffer. The keys are the
;; same as the defaults.
(("C-x C-=" . global-text-scale-adjust)
("C-x C-+" . global-text-scale-adjust)
("C-x C-0" . global-text-scale-adjust)))
(provide 'jw-emacs-theme)

Enable line numbers globally, but not in the following modes: org, term, shell, and eshell.

In addition to line numbers, the column number will also be displayed.

;;; jw-emacs-essentials.el --- Essential editor defaults -*- lexical-binding: t; -*-
;; Enable column numbers
(column-number-mode)
(global-display-line-numbers-mode t)
;; Disable line numbers for some modes
(dolist (mode '(org-mode-hook
markdown-mode-hook
term-mode-hook
shell-mode-hook
eshell-mode-hook))
(add-hook mode (lambda () (display-line-numbers-mode 0))))

Since fill-paragraph wraps fill-column, we adjust the size of the fill-column variable.

(setq-default fill-column 80)

Helpful adds a lot of very helpful (get it?) information to Emacs’ describe- command buffers. For example, if you use describe-function, you will not only get the documentation about the function, you will also see the source code of the function and where it gets used in other places in the Emacs configuration. It is very useful for figuring out how things work in Emacs.

(use-package helpful
:bind
([remap describe-command] . helpful-command)
([remap describe-key] . helpful-key))
(setq auth-sources '("~/.authinfo" "~/.netrc"))

The jw-emacs-essentials.el call to provide

Section titled “The jw-emacs-essentials.el call to provide”
(provide 'jw-emacs-essentials)

The mode-line was disabled earlier (The init.el conditional to remove display of mode-line) so that the startup UI would look smooth

;;; jw-emacs-modeline.el --- Mode line configuration -*- lexical-binding: t; -*-
(setq-default mode-line-format (default-value 'mode-line-format))
(setq display-time-format "%l:%M %p %b %y"
display-time-default-load-average nil)

doom-modeline is a very attractive and rich (yet still minimal) mode line configuration for Emacs. The default configuration is quite good but you can check out the configuration options for more things you can enable or disable.

If you are running in the macos terminal, then you have to make sure that you set the font to Droid Sans Mono Nerd Font Complete 18. You can do this by the following steps:

  • Navigate to Settings
  • Navigate to Profiles tab
  • Navigate to Text subtab
  • Under the Font menu click on Change
  • Select the appropriate font
(use-package doom-modeline
:straight t
:init (doom-modeline-mode 1)
:custom ((doom-modeline-height 15)))

doom-modeline icons rely on nerd-icons. Thus, you must install the nerd-icons if you want to use the icons on the modeline.

IMPORTANT: must run the following command — Mx - nerd-icons-install-fonts for the icons to populate. See the github issue here: Doom Emacs Issue #7368

(use-package nerd-icons
;; :custom
;; The Nerd Font you want to use in GUI
;; "Symbols Nerd Font Mono" is the default and is recommended
;; but you can use any other Nerd Font if you want
;; (nerd-icons-font-family "Symbols Nerd Font Mono")
)

To turn off icons uncomment the following:

;; (setq doom-modeline-icon nil)

The following contains configurations of the doom-modeline. All the configurations here use the setq.

;; If non-nil, a word count will be added to the selection-info modeline segment.
(setq doom-modeline-enable-word-count t)
;; Major modes in which to display word count continuously.
;; Also applies to any derived modes. Respects `doom-modeline-enable-word-count'.
;; If it brings the sluggish issue, disable `doom-modeline-enable-word-count' or
;; remove the modes from `doom-modeline-continuous-word-count-modes'.
(setq doom-modeline-continuous-word-count-modes '(markdown-mode gfm-mode org-mode))

Display the virtual environment version.

(setq doom-modeline-env-version t)
(provide 'jw-emacs-modeline)
;;; jw-emacs-completion.el --- Completion frameworks -*- lexical-binding: t; -*-
;; for preserving minibuffer history
(use-package savehist
:straight t
:config
(setq history-length 25)
(savehist-mode 1))
;; Individual history elements can be configured separately
;;(put 'minibuffer-history 'history-length 25)
;;(put 'evil-ex-history 'history-length 50)
;;(put 'kill-ring 'history-length 25))

Completions with vertico.el

(defun jw/minibuffer-backward-kill (arg)
"When minibuffer is completing a file name delete up to parent
folder, otherwise delete a word"
(interactive "p")
(if minibuffer-completing-file-name
;; Borrowed from https://github.com/raxod502/selectrum/issues/498#issuecomment-803283608
(if (string-match-p "/." (minibuffer-contents))
(zap-up-to-char (- arg) ?/)
(delete-minibuffer-contents))
(delete-word (- arg))))
(use-package vertico
:straight t
:bind (:map vertico-map
("C-j" . vertico-next)
("C-k" . vertico-previous)
("C-f" . vertico-exit)
:map minibuffer-local-map
("M-h" . jw/minibuffer-backward-kill))
:custom
(vertico-cycle t)
:init
(vertico-mode))

Completions in region with corfu.el.

(use-package corfu
:straight t
;; Optional customizations
:custom
(corfu-cycle t) ;; Enable cycling for `corfu-next/previous'
(corfu-auto t) ;; Enable auto completion
(corfu-auto-prefix 2)
(corfu-auto-delay 0.8)
(corfu-popinfo-delay '(0.5 . 0.2))
(corfu-preview-current 'insert) ; insert previewed candidate
(corfu-preselect 'prompt)
;; (corfu-separator ?\s) ;; Orderless field separator
;; (corfu-quit-at-boundary nil) ;; Never quit at completion boundary
;; (corfu-quit-no-match nil) ;; Never quit, even if there is no match
;; (corfu-preview-current nil) ;; Disable current candidate preview
;; (corfu-preselect 'prompt) ;; Preselect the prompt
;; (corfu-on-exact-match nil) ;; Configure handling of exact matches
;; (corfu-scroll-margin 5) ;; Use scroll margin
:bind (:map corfu-map
("C-j" . corfu-next)
("C-k" . corfu-previous)
("C-f" . corfu-insert))
;; Enable Corfu only for certain modes.
;; :hook ((prog-mode . corfu-mode)
;; (shell-mode . corfu-mode)
;; (eshell-mode . corfu-mode))
;; Recommended: Enable Corfu globally. This is recommended since Dabbrev can
;; be used globally (M-/). See also the customization variable
;; `global-corfu-modes' to exclude certain modes.
:init
(global-corfu-mode))

Since corfu.el does not support running emacs in the terminal, I will just stick with company.el instead of corfu-terminal.

Company Mode provides a nicer in-buffer completion interface than completion-at-point which is more reminiscent of what you would expect from an IDE. We add a simple configuration to make the keybindings a little more useful (TAB now completes the selection and initiates completion at the current location if needed).

We also use company-box to further enhance the look of the completions with icons and better overall presentation.

(unless (display-graphic-p)
(progn
;; Configuration for GUI mode
(use-package company
:after eglot
:hook (eglot--managed-mode . company-mode)
:bind (:map company-active-map
("<tab>" . company-complete-selection))
(:map eglot-mode-map
("<tab>" . company-indent-or-complete-common))
:custom
(company-minimum-prefix-length 1)
(company-idle-delay 0.0))
(use-package company-box
:hook (company-mode . company-box-mode)))
;; Configuration for terminal mode (optional)
;; Add your terminal mode specific configuration here
)

Additional completions in region with cape.el.

(use-package cape
:straight t
:init
(add-to-list 'completion-at-point-functions #'cape-file)
(add-to-list 'completion-at-point-functions #'cape-dabbrev))

For candidate filtering.

(use-package orderless
:init
(setq completion-styles '(orderless)
completion-category-defaults nil
completion-category-overrides '((file (styles . (partial-completion))))))

For completion notations.

;;; Detailed completion annotations (marginalia.el)
(use-package marginalia
:straight t
:hook (after-init . marginalia-mode)
:config
(setq marginalia-max-relative-age 0)) ; absolute time

The jw-emacs-completion.el call to provide

Section titled “The jw-emacs-completion.el call to provide”
(provide 'jw-emacs-completion)

Set up Org Mode with a baseline configuration. The following sections will add more things to it.

;;; jw-emacs-org.el --- Org mode configuration -*- lexical-binding: t; -*-
(defun jw/org-mode-setup ()
(org-indent-mode) ;; auto-indentation for headings
(variable-pitch-mode 1) ;; cause fonts to vary by proportionality
(visual-line-mode 1)) ;; wrap the text so that it does not go out of view
(use-package org
:hook (org-mode . jw/org-mode-setup)
:config
(setq org-ellipsis " ▾") ;; when org headings closed down arrow instead of ellipsis
(setq org-M-RET-may-split-line '((default . nil))) ;; when auto generating subsequent headings, avoid splitting the line
(setq org-insert-heading-respect-content t) ;; when creating new heading respects the content of which heading it was originally
(setq org-log-done 'time)
(setq org-log-into-drawer t) ;; task change is in drawer instead of content
;; keywords for org task states
)

The agenda is deliberately a single file that never rotates. Per-week or per-project files buy structure at the cost of a decision on every capture, and tasks whose lifespan is one day do not earn that. Add a line, it shows up today; mark it DONE, it disappears. Naming the file explicitly (rather than scanning a directory at startup) also means a file created mid-session is never missing from the agenda.

(defvar jw-org-todo-file
(expand-file-name "~/Core/Otzar/Docs/agenda/todo.org")
"The one and only agenda file.
Referenced by the capture template in `jw-emacs-information-management'.")
;; Create the directory and the file on a fresh machine so that both
;; `org-agenda' and `org-capture' work without any manual setup.
(let ((agenda-dir (file-name-directory jw-org-todo-file)))
(unless (file-directory-p agenda-dir)
(make-directory agenda-dir t)))
(unless (file-exists-p jw-org-todo-file)
(with-temp-file jw-org-todo-file
(insert "#+title: Todo\n\n")))
(setq org-agenda-files (list jw-org-todo-file))
(setq org-todo-keywords
'((sequence "TODO(t)" "WAIT(w!)" "|" "CANCEL(c!)" "DONE(d!)")))
;; Show today, not the week ahead -- the agenda is a day's worklist, not a
;; project plan.
(setq org-agenda-span 'day)
(setq org-agenda-start-on-weekday nil)
(global-set-key (kbd "C-c a") #'org-agenda)
(global-set-key (kbd "C-c c") #'org-capture)

A single todo.org does not mean losing history --- the history is already being written, it just is not visible yet. org-log-done is time and the WAITCANCELDONE keywords carry !, so every state change stamps a CLOSED: line and a LOGBOOK drawer entry. Nothing extra needs recording; the only missing pieces are somewhere for finished work to go and a way to look at it.

Archiving goes to agenda/archive/, one file per year, each holding a datetree. The datetree/ prefix in org-archive-location is special: Org files each entry under a YYYY / YYYY-MM month / YYYY-MM-DD day heading, and org-archive.el takes that date from the entry’s CLOSED property rather than from the clock. So the layout is:

agenda/ todo.org <- the only agenda file archive/ 2026.org <- * 2026 / ** 2026-07 July / *** 2026-07-26 Sunday 2027.org

Year is the file boundary; month and day are headings inside it. A file per month would give sixty files after five years, which is worse for grepping and is the proliferation this setup exists to avoid --- and a year is the only unit anyone ever retires whole.

The year comes from each entry’s CLOSED date, not from today. That is what makes the archive safe to neglect: a sweep run in January still files December’s work under 2026.org. Computing it per entry also keeps a session left open across New Year from writing into the wrong file.

org-archive-location is deliberately not set globally --- it is bound only inside the command below. Set globally it would redirect archiving from every Org file, including denote notes, into the agenda archive. Everything outside todo.org keeps Org’s default of a <file>.org_archive sibling.

(defvar jw-org-archive-directory
(expand-file-name "~/Core/Otzar/Docs/agenda/archive/")
"Directory holding one Org archive file per year.")
(defun jw-org-archive-location-for-entry ()
"Return an `org-archive-location' for the finished entry at point.
The year is taken from the entry's CLOSED timestamp so that a late sweep
still files work under the year it was actually finished."
(let* ((closed (org-entry-get nil "CLOSED" t))
(year (format-time-string
"%Y"
(if closed (org-time-string-to-time closed) (current-time)))))
(concat (expand-file-name (concat year ".org") jw-org-archive-directory)
"::datetree/")))
(defun jw-org-archive-done ()
"Archive every finished entry in `jw-org-todo-file' to the year datetrees.
`org-entry-is-done-p' tests membership in `org-done-keywords', so both DONE
and CANCEL qualify -- they sit after the `|' in `org-todo-keywords'."
(interactive)
(unless (file-directory-p jw-org-archive-directory)
(make-directory jw-org-archive-directory t))
(with-current-buffer (find-file-noselect jw-org-todo-file)
(let ((count 0))
(org-map-entries
(lambda ()
(when (org-entry-is-done-p)
;; Bound per entry, not globally: see the note above.
(let ((org-archive-location (jw-org-archive-location-for-entry)))
(org-archive-subtree))
(setq count (1+ count))
;; `org-archive-subtree' removes the entry, which leaves the
;; mapper's saved position stale; `org-map-continue-from' is the
;; documented way to tell it where to resume.
(setq org-map-continue-from (point))))
t 'file)
(save-buffer)
(message "Archived %d finished %s" count
(if (= count 1) "entry" "entries")))))
(global-set-key (kbd "C-c A") #'jw-org-archive-done)

The whole loop is two keys:

  • C-c c t --- capture a task. Type it, C-c C-c to file. The template stamps SCHEDULED: %t (today), so it appears in the agenda with no further action.
  • C-c a a --- open the day’s agenda. t on a line cycles its state, RET jumps to it in todo.org.

The states are TODO -> WAIT -> CANCEL/DONE. WAIT, CANCEL and DONE all have ! in their definition, so switching to them logs a timestamp into a LOGBOOK drawer (org-log-into-drawer is t above) --- history without cluttering the visible outline.

Things that are deliberately not here, because each one buys structure at the cost of a decision per capture: priorities, effort estimates, projects, sub-headings, per-week or per-project files, and refile targets. If a task needs more scaffolding than a single line, it is not a task --- it is a note, and it belongs in the vault (see obsidian.el).

The built-in log view needs no additional setup:

  • l inside the agenda toggles org-agenda-log-mode, which shows entries closed on the displayed day instead of the ones still open. With org-agenda-span set to day, moving back with b and pressing l is a read-out of what a given day actually looked like.

Do not rely on v A for the custom yearly files. That command discovers archive files from the active value of org-archive-location, while this setup intentionally binds the location only during jw-org-archive-done. The year files are ordinary Org files: open one and fold its datetree to skim a month, or grep across agenda/archive/ when the year is not known. Keeping the granularity at one file per year is what makes that grep practical.

DONE items leave the agenda view the moment they are marked, so they cost nothing day to day and there is no cleanup habit to maintain. When the file starts feeling heavy, C-c A (jw-org-archive-done) sweeps every finished entry into the datetree in one go.

Doing this rarely is fine, and is in fact the intended use: because entries file under their own CLOSED date --- both into the right year file and the right day heading --- a sweep after two months of neglect produces exactly the same archive as sweeping every Friday. That is the whole reason to derive the layout from the data rather than from the calendar at run time: the accuracy of the history does not depend on the discipline of the person maintaining it.

C-c A is the only archiving path for todo.org. C-c C-x C-a still works everywhere, but since org-archive-location is left at its default outside this command, in todo.org it would create a todo.org_archive sibling instead of filing into the year tree. Sweeping is not worth avoiding --- it only ever moves entries that are already finished.

Configure for macos to play sound:

;; on macos, fix "This Emacs binary lacks sound support"
;; - https://github.com/leoliu/play-sound-osx/blob/master/play-sound.el
;; - update according to https://github.com/leoliu/play-sound-osx/issues/2#issuecomment-1088360638
(when (eq system-type 'darwin)
(unless (and (fboundp 'play-sound-internal)
(subrp (symbol-function 'play-sound-internal)))
(defun play-sound-internal (sound)
"Internal function for `play-sound' (which see)."
(or (eq (car-safe sound) 'sound)
(signal 'wrong-type-argument (list sound)))
(cl-destructuring-bind (&key file data volume device)
(cdr sound)
(and (or data device)
(error "DATA and DEVICE arg not supported"))
(apply #'start-process "afplay" nil
"afplay" (append (and volume (list "-v" volume))
(list (expand-file-name file data-directory))))))))
(setq org-clock-sound "~/.dotfiles/.assets/sounds/mixkit-alert-quick-chime-766.wav")

The following is for sound support,

Usually this is a problem for macos and I found a snippet of code that enables sound support. The way to tell is by running M-x play-sound-file and navigating to the .wav file will ouput “This Emacs binary lacks sound support.”

;; on macos, fix "This Emacs binary lacks sound support"
;; - https://github.com/leoliu/play-sound-osx/blob/master/play-sound.el
;; - update according to https://github.com/leoliu/play-sound-osx/issues/2#issuecomment-1088360638
(when (eq system-type 'darwin)
(unless (and (fboundp 'play-sound-internal)
(subrp (symbol-function 'play-sound-internal)))
(defun play-sound-internal (sound)
"Internal function for `play-sound' (which see)."
(or (eq (car-safe sound) 'sound)
(signal 'wrong-type-argument (list sound)))
(cl-destructuring-bind (&key file data volume device)
(cdr sound)
(and (or data device)
(error "DATA and DEVICE arg not supported"))
(apply #'start-process "afplay" nil
"afplay" (append (and volume (list "-v" volume))
(list (expand-file-name file data-directory))))))))

As recommended by the official org manual to have these keys bound.

(global-set-key (kbd "C-c l") 'org-store-link)
(global-set-key (kbd "C-c C-l") 'org-insert-link)

Instead of relying on just the path/etc, allow orgmode to use unique IDs to create internal links that can point to specific headings in org files.

With the create-if-interactive setting, it only creates in interactive settings.

(setq org-id-link-to-org-use-id 'create-if-interactive)

Customize the heading bullets to make it consistent and nicer.

(use-package org-bullets
:after org
:hook (org-mode . org-bullets-mode)
:custom
(org-bullets-bullet-list '("◉" "○" "●" "○" "●" "○" "●")))
(setq org-image-actual-width nil)
(setq org-startup-with-inline-images t)
(add-hook 'org-mode-hook 'org-display-inline-images)

These structured templates are used to auto generate code blocks for org mode. In order to use the template simply type < followed by the abbreviation of the language and hit the TAB button. For example, the python snippit would be <py TAB.

;; This is needed as of Org 9.2
(require 'org-tempo)
(add-to-list 'org-structure-template-alist '("sh" . "src shell"))
(add-to-list 'org-structure-template-alist '("el" . "src emacs-lisp"))
(add-to-list 'org-structure-template-alist '("py" . "src python"))
(add-to-list 'org-structure-template-alist '("clang" . "src c"))
(add-to-list 'org-structure-template-alist '("cpp" . "src cpp"))

This snippet adds a hook to org-mode buffers so that jw/org-babel-tangle-config gets executed each time such a buffer gets saved. This function checks to see if the file being saved is the Emacs.org file you’re looking at right now, and if so, automatically exports the configuration here to the associated output files.

;; Automatically tangle our Emacs.org config file when we save it
(defun jw/org-babel-tangle-config ()
(when (string-equal (buffer-file-name)
(expand-file-name "~/.dotfiles/Emacs.org"))
;; Dynamic scoping to the rescue
(let ((org-confirm-babel-evaluate nil))
(org-babel-tangle))))
(add-hook 'org-mode-hook (lambda () (add-hook 'after-save-hook #'jw/org-babel-tangle-config)))

To execute or export code in org-mode code blocks, you’ll need to set up org-babel-load-languages for each language you’d like to use. This page documents all of the languages that you can use with org-babel.

(org-babel-do-load-languages
'org-babel-load-languages
'((emacs-lisp . t)
(python . t)))
(push '("conf-unix" . conf-unix) org-src-lang-modes)

visual-fill-column will create a document looking display with the extra padding on the left and on the right.

(defun jw/org-mode-visual-fill ()
(setq visual-fill-column-width 100
visual-fill-column-center-text t)
(visual-fill-column-mode 1))
(use-package visual-fill-column
:hook (org-mode . jw/org-mode-visual-fill)
(markdown-mode . jw/org-mode-visual-fill))
(with-eval-after-load 'ox-latex
(add-to-list 'org-latex-classes
'("org-plain-latex"
"\\documentclass{article}
[NO-DEFAULT-PACKAGES]
[PACKAGES]
[EXTRA]"
("\\section{%s}" . "\\section*{%s}")
("\\subsection{%s}" . "\\subsection*{%s}")
("\\subsubsection{%s}" . "\\subsubsection*{%s}")
("\\paragraph{%s}" . "\\paragraph*{%s}")
("\\subparagraph{%s}" . "\\subparagraph*{%s}")))
(add-to-list 'org-latex-classes
'("org-plain-no-section-numbering-latex"
"\\documentclass{article}
[NO-DEFAULT-PACKAGES]
[PACKAGES]
[EXTRA]"
("\\section*{%s}" . "\\section*{%s}")
("\\subsection*{%s}" . "\\subsection*{%s}")
("\\subsubsection*{%s}" . "\\subsubsection*{%s}")
("\\paragraph*{%s}" . "\\paragraph*{%s}")
("\\subparagraph*{%s}" . "\\subparagraph*{%s}"))))
(provide 'jw-emacs-org)
;;; jw-emacs-git.el --- Git and version control -*- lexical-binding: t; -*-
(require 'package)
(add-to-list 'package-archives
'("melpa" . "https://melpa.org/packages/") t)
(setq forge-add-default-bindings nil)
(use-package magit
:custom
(magit-display-buffer-function #'magit-display-buffer-same-window-except-diff-v1))
;; NOTE: Make sure to configure a GitHub token before using this package!
;; - https://magit.vc/manual/forge/Token-Creation.html#Token-Creation
;; - https://magit.vc/manual/ghub/Getting-Started.html#Getting-Started
(use-package forge
:after magit
)
(use-package evil-collection
:after (evil forge)
:config
(evil-collection-init)
(evil-collection-forge-setup))

When on the commit buffer, the argument for gpg-signing or -S may not be displayed. To resolve this issue manually, on the commit buffer menu, you must enter transient mode with C-x l and follow the prompting from there by typing the argument that you want to change the layering and then set the layering.

(provide 'jw-emacs-git)
;;; jw-emacs-dired.el --- Dired configuration -*- lexical-binding: t; -*-
(setq dired-listing-switches "-alD")

For macos, make sure to have coreutils installed. To install run, brew install coreutils

(setq insert-directory-program "gls"
dired-use-ls-dired t)
(provide 'jw-emacs-dired)

The jw-emacs-information-management.el module

Section titled “The jw-emacs-information-management.el module”
;;; jw-emacs-information-management.el --- Notes and information management -*- lexical-binding: t; -*-
(use-package denote
:straight t)
(setq denote-directory (expand-file-name "~/Core/Otzar/Gnosis/"))
;; Create the notes directory if it does not exist yet (e.g. fresh machine).
(unless (file-directory-p denote-directory)
(make-directory denote-directory t))
(setq denote-save-buffer-after-creation nil)

Enable the denote dired mode for all files so that the components can easily be seen.

(add-hook 'dired-mode-hook #'denote-dired-mode)

Keywords are the same vocabulary used for tags in the Obsidian vault --- see Tags for what each one covers and why the list is short. One vocabulary across both tools means one thing to remember; two would guarantee drift.

journal is not in the list: denote notes that are a dated log are better served by the vault’s daily-notes/, and a keyword that duplicates a location earns nothing.

(setq denote-known-keywords
'("math" "markets" "hf" "code" "infra"
"philosophy" "ministry" "health" "people" "writing"))
;; Offer keywords already present in the notes, not only the list above, so
;; the vocabulary can drift without editing this file.
(setq denote-infer-keywords t)
(setq denote-sort-keywords t)
(setq denote-file-type nil) ; Org is the default, set others here
(setq denote-prompts '(subdirectory title keywords))
(setq denote-excluded-directories-regexp nil)
(setq denote-excluded-keywords-regexp nil)
(setq denote-rename-no-confirm nil) ; Set to t if you are familiar with `denote-rename-file'
;; Pick dates, where relevant, with Org's advanced interface:
(setq denote-date-prompt-use-org-read-date t)
;; Read this manual for how to specify `denote-templates'. We do not
;; include an example here to avoid potential confusion.
(setq denote-date-format nil) ; read doc string
;; By default, we do not show the context of links. We just display
;; file names. This provides a more informative view.
(setq denote-backlinks-show-context t)
;; Also see `denote-link-backlinks-display-buffer-action' which is a bit
;; advanced.
;; If you use Markdown or plain text files (Org renders links as buttons
;; right away)
(add-hook 'find-file-hook #'denote-fontify-links-mode-maybe)
(with-eval-after-load 'org-capture
(setq denote-org-capture-specifiers "%l\n%i\n%?")
(add-to-list 'org-capture-templates
'("n" "New note (with denote.el)" plain
(file denote-last-path)
#'denote-org-capture
:no-save t
:immediate-finish nil
:kill-buffer t
:jump-to-captured t)))
;; Also check the commands `denote-link-after-creating',
;; `denote-link-or-create'. You may want to bind them to keys as well.
;; If you want to have Denote commands available via a right click
;; context menu, use the following and then enable
;; `context-menu-mode'.
(add-hook 'context-menu-functions #'denote-context-menu)

The capture template for the jw-emacs-org.el section for org-agenda. SCHEDULED: %t stamps it with today, so a captured item appears in the day agenda without any further filing.

This replaces an earlier jw-denote-weekly-tasks-filename that minted a new Denote-named file with frontmatter every week. Weekly files are a filing system for things that live one day, and the rotation was pure overhead.

Note the use of add-to-list rather than setq: the denote note template below registers itself the same way, and a bare setq here silently clobbers it depending on load order.

;; Ensure denote.el is loaded
(require 'denote)
(with-eval-after-load 'org-capture
(add-to-list 'org-capture-templates
`("t" "Todo (today)" entry
(file ,jw-org-todo-file)
"* TODO %?\nSCHEDULED: %t"
:empty-lines 1)))

obsidian.el treats a directory of Markdown files as a knowledge base: [[wiki-links]], #tags, jumping, search, and a backlinks panel. This vault is the knowledge bank --- things worth referencing later. Tasks live in org-agenda; denote.el holds Org-format notes.

The vault directories are created before the path is assigned because obsidian-directory has a :set handler that refuses a missing directory. setopt (not setq) is what runs that handler, which also records the path length used to compute vault-relative names.

(use-package obsidian
:straight t
:demand t
:config
(let ((vault (expand-file-name "~/Core/Otzar/Obsidian/")))
(dolist (dir (list vault
(expand-file-name "notes" vault)
(expand-file-name "daily-notes" vault)
(expand-file-name "templates" vault)))
(unless (file-directory-p dir)
(make-directory dir t)))
;; Seed the templates. `obsidian-daily-note' inserts its one without
;; checking that it exists; both are left alone once present, so they can
;; be edited in the vault without this reverting them.
(pcase-dolist (`(,name . ,body)
'(("Daily Note Template.md" . "# {{title}}\n\n")
("Note.md" . "---\ncreated: {{date}}\ntags: []\n---\n\n# {{title}}\n\n<!-- the claim, in one sentence -->\n\n## Why\n\n## Sources\n")))
(let ((file (expand-file-name (concat "templates/" name) vault)))
(unless (file-exists-p file)
(with-temp-file file (insert body)))))
(setopt obsidian-directory vault))
;; Track vault files everywhere so links and tags resolve globally.
(global-obsidian-mode t))

Backlinks are a toggle, not a default. obsidian-backlinks-mode is a global minor mode that opens its side window the instant it is enabled and hooks buffer-list-update-hook, so enabling it at load time puts a panel in every session --- including ones that never touch the vault. C-c n b when it is actually wanted.

Vault-relative directories; nil disables a feature.

(setq obsidian-inbox-directory "notes") ; destination for `obsidian-capture'
(setq obsidian-daily-notes-directory "daily-notes") ; daily note file is YYYY-MM-DD.md
(setq obsidian-templates-directory "templates") ; note templates live here
(setq obsidian-daily-note-template "Daily Note Template.md")
;; Following a wiki-link to a note that does not exist yet creates it in
;; `obsidian-inbox-directory' rather than beside the current file.
(setq obsidian-create-unfound-files-in-inbox t)

The capture folder is notes/ rather than the usual inbox/ because an inbox is an implicit promise to process it later; nothing here needs to graduate anywhere.

Notes are read in the Obsidian GUI, which renders math with MathJax, but they are written here --- and markdown-enable-math defaults to nil, so $...$ would otherwise get no syntax highlighting at all. It is buffer-local, hence setq-default.

(setq-default markdown-enable-math t)

Note that LaTeX cannot produce phantom tags: obsidian--tag-regex requires the # to follow whitespace or start-of-line and be followed by at least two letter-like characters, so neither a \newcommand parameter (#1, digit) nor an escaped \# (preceded by a backslash) is picked up.

In Obsidian, \begin{...} only renders inside $$ ... $$ --- MathJax sees only what is between math delimiters, unlike real LaTeX where align is its own display environment. A blank line inside the block also terminates it, so multi-line derivations must use \\ throughout with no empty lines.

Entry points are global so the vault is reachable from anywhere; link and backlink commands are scoped to vault buffers, where they are the natural C-c C-* verbs.

| Key | Command | Does | |-----------+--------------------------------+---------------------------------------| | C-c n n | obsidian-daily-note | today’s daily note, from the template | | C-c n c | jw-obsidian-capture | new named note in notes/, templated | | C-c n j | obsidian-jump | fuzzy-jump to any note | | C-c n s | obsidian-search | full-text search the vault | | C-c n g | xeft | search-as-you-type with context | | C-c n t | jw-obsidian-add-tag | add a tag, completing on existing | | C-c n f | obsidian-find-tag | list notes carrying a tag | | C-c n i | jw-obsidian-insert-template | insert a template into this buffer | | C-c n b | obsidian-backlinks-mode | toggle the backlinks panel | | C-c n u | obsidian-update | re-scan after edits made outside Emacs | | C-c C-o | obsidian-follow-link-at-point | follow the link under point | | C-c C-b | obsidian-backlink-jump | jump to a note linking here | | C-c C-l | obsidian-insert-wikilink | insert a [[wiki]] link |

;; Entry points: reachable from anywhere, not only from inside the vault.
(global-set-key (kbd "C-c n n") #'obsidian-daily-note)
(global-set-key (kbd "C-c n c") #'jw-obsidian-capture)
(global-set-key (kbd "C-c n j") #'obsidian-jump)
(global-set-key (kbd "C-c n s") #'obsidian-search)
(global-set-key (kbd "C-c n t") #'jw-obsidian-add-tag)
(global-set-key (kbd "C-c n f") #'obsidian-find-tag)
(global-set-key (kbd "C-c n i") #'jw-obsidian-insert-template)
(global-set-key (kbd "C-c n b") #'obsidian-backlinks-mode)
(global-set-key (kbd "C-c n u") #'obsidian-update)
(with-eval-after-load 'obsidian
(define-key obsidian-mode-map (kbd "C-c C-o") #'obsidian-follow-link-at-point)
(define-key obsidian-mode-map (kbd "C-c C-b") #'obsidian-backlink-jump)
(define-key obsidian-mode-map (kbd "C-c C-l") #'obsidian-insert-wikilink))

obsidian-search is a one-shot grep: type a phrase, get a hit list. xeft is search-as-you-type across the whole vault, showing each match with its surrounding context, and RET on a phrase with no match creates a note by that name. For a knowledge bank whose main failure mode is not remembering what a note was called, that live-filtering loop is the more useful retrieval path --- so both are kept, on adjacent keys.

obsidian.el supplies the two functions that make xeft vault-aware: obsidian-file-p excludes anything that is not a vault Markdown file, and obsidian-file-title-function reads a title: from front matter, falling back to the first line and then the file name. Deriving xeft-directory from obsidian-directory keeps one source of truth for the vault path; =:after obsidian= is what guarantees that variable is already set.

Upstream binds this to C-c C-g in obsidian-mode-map. It is bound globally here instead --- searching is how you get into the vault, so a binding that only works once you are already in it is backwards. (C-c C-g also means “refresh” inside xeft’s own buffer, so reusing it for entry reads oddly.)

(use-package xeft
:straight t
:after obsidian
:bind ("C-c n g" . xeft)
:custom
(xeft-directory obsidian-directory)
(xeft-recursive t) ; notes/, daily-notes/, ...
(xeft-file-filter #'obsidian-file-p)
(xeft-title-function #'obsidian-file-title-function))

xeft searches through a Xapian index, which means a dynamic module --- this is the one piece of the config with a dependency outside Emacs:

Terminal window
brew install xapian

Emacs must also have been built with module support, and a C++ compiler and make must be present. The first M-x xeft calls xeft--require-xapian-lite, which finds no module and offers three choices: compile locally, download a prebuilt binary, or quit. Compiling is the honest option once xapian is installed; it runs make in xeft’s own directory and is a one-time cost.

If it cannot proceed it says =Cannot start xeft because required dynamic module is missing= --- that message means the module, not the package, so the fix is brew install xapian and re-running the compile prompt rather than reinstalling xeft.

Note that templates/ is indexed along with everything else, so template files surface in results. With one or two templates that is not worth filtering; if it becomes noise, wrap obsidian-file-p in a predicate that rejects that subdirectory.

obsidian.el applies a template in exactly one place. obsidian-capture and obsidian-daily-note are otherwise the same function --- prompt or date for a name, find-file, save-buffer --- but only the daily note goes on to check obsidian-daily-note-template and apply it. A captured note therefore starts completely empty, with no front matter, which would put the created date and tags on the wrong side of a manual step.

The wrapper below adds the missing branch, reusing the daily note’s own guard: apply the template only when the buffer is empty, so an existing note is never overwritten.

(defvar jw-obsidian-note-template "Note.md"
"Template in `obsidian-templates-directory' applied by `jw-obsidian-capture'.")
(defun jw-obsidian-capture ()
"Capture a note like `obsidian-capture', then apply `jw-obsidian-note-template'.
`obsidian-capture' applies no template -- only `obsidian-daily-note' does --
so a captured note would otherwise start with no front matter at all."
(interactive)
(call-interactively #'obsidian-capture)
(when (and obsidian-templates-directory
jw-obsidian-note-template
(eq (buffer-size) 0))
(obsidian-apply-template
(expand-file-name jw-obsidian-note-template
(expand-file-name obsidian-templates-directory
obsidian-directory)))
(save-buffer)))

obsidian-apply-template is also not an interactive command, so there is no built-in way to reach a template from an existing buffer --- for a note that started life without one, or to pull in a second template. This adds that:

(defun jw-obsidian-insert-template ()
"Insert a template from `obsidian-templates-directory' into this buffer.
Substitutes {{title}}, {{date}} and {{time}} the same way `obsidian-daily-note'
does, since it reuses `obsidian-apply-template'."
(interactive)
(let* ((dir (expand-file-name obsidian-templates-directory obsidian-directory))
(templates (directory-files dir nil "\\.md\\'")))
(unless templates
(user-error "No templates in %s" dir))
(obsidian-apply-template
(expand-file-name (completing-read "Template: " templates) dir))))

The substitutions available are \{\{title\}\} (the file name), \{\{date\}\} and \{\{time\}\}. There is no prompting and no cursor placement --- these are skeletons, not Org capture templates.

Keep them few and nearly empty. A template’s cost is not writing it, it is that every section in it becomes an obligation: headings that get left blank accumulate across hundreds of notes and make the vault look like work in progress rather than a reference. The daily note template seeded above is just # {{title}} for exactly this reason --- a daily note’s value is having no structure to satisfy.

A second template is worth it only for a shape that is genuinely repeated, and the one that pays here is the durable note --- it encodes the handoff rules below (claim first, sources kept) and carries the small amount of front matter that is worth having. Both templates are seeded on first load, next to the vault directories.

Front matter is first-class in obsidian.el --- obsidian--process-front-matter-tags reads tags: from it, and obsidian-insert-tag notices when point is inside front matter and omits the #. But only two fields earn their place:

  • tags --- the domain, per Tags. Front matter keeps them in one place instead of scattered through the prose. The template ships tags: [], which is the correct empty value: obsidian.el parses it to an empty vector and returns no tags silently, whereas a bare tags: parses to :null and warns “The key ‘tags’ cannot have an empty value in front matter”.
  • created --- the filesystem does record a birth time, but copying, syncing and git checkout all destroy it, and it cannot be reconstructed afterwards.

Two fields deliberately left out:

  • title --- obsidian-file-title-function reads title:, then falls back to the first line, then the file name. Since the template’s first line is # {{title}}, a title: field only repeats the file name.
  • updated / last edited --- keeping it accurate means a hook rewriting the file on every save, which turns each trivial edit into a diff and goes stale silently as soon as a file is edited outside Emacs. Git already records this exactly: git log -1 --format%cI — . created= is the one date git cannot recover after a move or an import, which is why it is the one kept.

Denote-style YYYYMMDDTHHMMSS--name filenames are deliberately not used in the vault. Wiki-links resolve by exact file name --- obsidian--match-files compares the link text against relative paths --- so [[Basis Trade]] finds Basis Trade.md and would not find 20260726T143022--basis-trade.md.

Aliases do not rescue it: obsidian-jump consults obsidian--aliases-map, but obsidian-follow-wiki-link-at-point goes through obsidian--match-files, which only sees file names. The result would be jumping that works while every [[link]] quietly creates a new empty note instead of finding the existing one.

The difference is structural. Denote’s timestamp is its identifier, so it has to be in the file name --- there is no other index. This vault’s index is the link graph and its identifier is the human name. denote.el is already configured for when ID-stable, rename-safe notes are wanted; the vault should not become a second, weaker copy of it.

The rule that keeps tags from turning into chaos: tags carry the domain, links carry the topic.

Links are unbounded and cost nothing --- every [[wiki-link]] is one more retrieval path, and a wrong one is harmless. Tags are the opposite: a tag is only useful if many notes share it, so every new tag slightly devalues the existing ones. #trading, #trades and #markets as three separate tags means none of them reliably returns everything.

So topics are never tags. What a note is about belongs in its title and its links. Tags answer only “which pile do I want to walk through?”, which in practice means a handful of life domains. The same list is set as denote-known-keywords above, so both tools share one vocabulary.

Three habits are enough to hold the line:

  1. Add tags with C-c n t, never by typing them. Completion over tags already in the vault makes reuse the path of least resistance; typing # by hand is how a second spelling gets in.
  2. Keep the vocabulary memorable. Roughly ten tags, few enough to recall without looking. Adding one should feel like a decision; if a new tag overlaps an old one, pick one and rename rather than keeping both.
  3. One tag is usually right, three is a lot. If a note needs many tags to be findable, it is really several notes, or it wants a link instead.

obsidian-insert-tag only inserts at point --- its sole piece of intelligence is checking obsidian-point-in-front-matter-p to decide whether to prefix a #. It does not find the tags: list, so using the front matter with it means parking the cursor between the brackets and typing the commas by hand. Since the note template ships tags: [], that friction would land on every note; this merges into the list instead, and falls back to an inline #tag when there is no front matter to merge into:

(defun jw-obsidian-add-tag (tag)
"Add TAG to the front-matter `tags:' list, completing on tags in the vault.
Merges into the bracketed list rather than inserting at point, so the list
stays comma-separated and free of duplicates. Falls back to inserting an
inline #TAG at point when the buffer has no front-matter `tags:' list.
Vault tags carry no leading `#', per the `obsidian-tags' docstring."
(interactive
(list (completing-read "Tag: " (sort (obsidian-tags) #'string<))))
(let ((merged
(save-excursion
(goto-char (point-min))
(when (looking-at-p "^---[ \t]*$")
(forward-line 1)
(when-let* ((end (save-excursion
(re-search-forward "^---[ \t]*$" nil t))))
(when (re-search-forward "^tags:[ \t]*\\[\\([^]]*\\)\\]" end t)
(let* ((current (split-string (match-string 1) "[,[:space:]]+" t))
(all (delete-dups (append current (list tag)))))
(replace-match
(concat "tags: [" (mapconcat #'identity all ", ") "]")
t t)
t)))))))
(unless merged
(insert (format "#%s" tag)))))

Front matter and body tags have opposite spelling rules, which is the other reason to go through a command rather than typing. In front matter a tag is bare (tags: [hf, markets]); obsidian--process-front-matter-tags silently drops any entry that starts with # or contains a space, warning “Found invalid tags in front matter”. In the body it is the reverse --- #hf, hash required.

The starting vocabulary:

| Tag | Covers | |--------------+------------------------------------------------------------| | math | math, statistics, probability, optimization | | markets | instruments, microstructure, macro, execution mechanics | | hf | the fund itself --- strategy, thesis, ops, building it | | code | programming, languages, libraries, tooling | | infra | homelab, servers, backups, self-hosting | | philosophy | analytic reasoning, ethics, metaphysics | | ministry | faith, practice, teaching | | health | training, sleep, food | | people | thinkers, contacts, who believes what | | writing | drafts and arguments in progress |

The test for whether two candidates should be one tag: can you name a case where seeing the other pile would be noise? Statistics is folded into math because the answer is no --- both are wanted on essentially every search, and splitting them puts a filing decision on exactly the boundary cases (stochastic calculus, measure-theoretic probability) where it is hardest to make.

hf and markets are the closest call in the other direction. They stay apart because merging them would put one tag on most of the vault, and a tag matching 60% of notes filters nothing --- the same failure as a tag matching one note. The line is learned versus built: how a limit order book works is markets; how this fund’s strategy works is hf. That is also where the separate-vault split would fall if the firm bank ever becomes real.

Deliberately absent is journal: daily notes already live in daily-notes/, and tagging what the location already says is pure noise. The last two rows are the least certain --- domains are discovered from use rather than designed up front, so treat any row that stays unused after a few months as a slot to reclaim.

This list is documentation, not configuration. Keep the living copy as a note in the vault so it is visible while writing; a vocabulary meant to drift slowly should not need a re-tangle to change.

Retrieval is C-c n f (obsidian-find-tag) for a domain, then obsidian-backlink-jump (C-c C-b) to walk outward through links. The tag narrows to a pile; the links do the actual navigating.

If you can name it, it is a note. If you cannot, it goes in today’s daily note.

Naming is the only filing decision in the system, and it can be made instantly or not at all. The commands mirror it: obsidian-capture (C-c n c) asks for a title, obsidian-daily-note (C-c n n) asks nothing.

The asymmetry that matters: an inbox carries debt and a daily note does not. An unprocessed daily note is still a perfectly good log --- it was never going to become anything else.

Section titled “Promotion happens through links, not filing”

There is no review step and no “process the inbox” ritual. When a line in a daily note keeps recurring, write it as a [[wiki-link]] and follow it with C-c C-o; the note gets created in notes/. Writing the link is the promotion, and it is correct by construction, because writing the link means it has already been named.

Editing: mechanics get overwritten, judgments get logged
Section titled “Editing: mechanics get overwritten, judgments get logged”

Notes are edited in place, not superseded by later iterations. Keeping “Basis Trade”, “Basis Trade v2” and “Basis Trade (revised)” side by side means reconstructing the truth by reading three documents and diffing them.

  • Mechanics --- how something works, a definition, a procedure. Overwrite it. There should be one note and it should be currently correct.
  • Judgments --- a view, a thesis, a call. Here the evolution is the content, so keep a dated log inside the single note.

Append-only feels like discipline, but its real motivation is fear of losing provenance, and that is already solved: keep the vault under git. A knowledge bank is measured by the cost of retrieving a correct answer, and versioned notes trade that away for a problem git log already handles.

This vault is for ideation. The real quant knowledge base is a later, separate thing with a designated team and a technical librarian compiling it, so what transfers out of here is content and provenance --- not structure. Nothing should be arranged now for eventual compatibility with a system nobody has designed yet.

A writer can turn a rough correct idea into good prose but cannot reconstruct why something was believed. So: lead with the claim (first line states the assertion, not the background), do not polish (prose is the part being outsourced), and keep the sources (links and citations cannot be recovered later). A bare pasted URL counts --- capture beats formatting.

Footnotes are deliberately unused, though both halves support them (Obsidian renders [^1] markers, and markdown-insert-footnote is on C-c C-a f). They are a presentation device for a reader who is not you, and the template’s ## Sources heading already holds provenance without anchors to maintain. A librarian can convert a source list into footnotes mechanically; nobody can recover a source that was never written down.

The distinction worth keeping straight is footnote versus link, since only one of them is lossy. A wiki-link means “this deserves its own note and may be reached from elsewhere”; a footnote means “this supports this sentence and nowhere else”. Footnoting something that deserved a link buries it where obsidian-find-tag, backlinks and xeft’s title matching cannot see it --- the reverse mistake costs nothing.

One decision that is cheap now and near-impossible later: when that firm bank becomes real, make it a separate vault --- not for tidiness, but for the moment other people get access.

Move the #<FILE># to a temporary directory instead of root directory.

(setq backup-directory-alist `(("." . ,(expand-file-name "tmp/backups/" user-emacs-directory))))
(setq lock-file-name-transforms
'(("\\`/.*/\\([^/]+\\)\\'" "/var/tmp/\\1" t)))

The jw-emacs-information-management.el call to provide

Section titled “The jw-emacs-information-management.el call to provide”
(provide 'jw-emacs-information-management)

Make sure to run M-x pdf-tools-install after installation.

;;; jw-emacs-productivity.el --- Productivity tools -*- lexical-binding: t; -*-
(use-package pdf-tools
:straight t
:config
(pdf-tools-install)
:hook (pdf-view-mode . (lambda ()
(display-line-numbers-mode -1)
(message "PDF Tools activated for this buffer"))))

The issue with the 2025-01-14 is that if the installation works within the command line, when opening up a pdf file on Emacs would lead to the epdfserver crashing. This issue I found had to do with confict with macports being installed. If you uninstall macports, then the issue is resolved.

If you receive the option to rebuild the epdfserver and you agree to building on Emacs, there are instances where the build fails. When running M-x pdf-tools-install you will rebuild within Emacs and will obtain more information. If the error consists of not being able to find poppler, copy and paste the command used to run the installation and run it in the command line outside of emacs.

;; Ensure org-noter is installed
(use-package org-noter
:straight t
:after (org pdf-tools)
:config
(setq org-noter-always-create-frame nil))
;; Ensure org-pdftools is set up to work with org-mode
(use-package org-pdftools
:straight t
:hook (org-mode . org-pdftools-setup-link))
;; Configure org-noter-pdftools
(use-package org-noter-pdftools
:after (org-noter pdf-tools)
:config
;; Add a function to ensure precise note is inserted
(defun org-noter-pdftools-insert-precise-note (&optional toggle-no-questions)
(interactive "P")
(org-noter--with-valid-session
(let ((org-noter-insert-note-no-questions (if toggle-no-questions
(not org-noter-insert-note-no-questions)
org-noter-insert-note-no-questions))
(org-pdftools-use-isearch-link t)
(org-pdftools-use-freepointer-annot t))
(org-noter-insert-note (org-noter--get-precise-info)))))
;; Fix for the specific issue
(defun org-noter-set-start-location (&optional arg)
"When opening a session with this document, go to the current location.
With a prefix ARG, remove start location."
(interactive "P")
(org-noter--with-valid-session
(let ((inhibit-read-only t)
(ast (org-noter--parse-root))
(location (org-noter--doc-approx-location (when (called-interactively-p 'any) 'interactive))))
(with-current-buffer (org-noter--session-notes-buffer session)
(org-with-wide-buffer
(goto-char (org-element-property :begin ast))
(if arg
(org-entry-delete nil org-noter-property-note-location)
(org-entry-put nil org-noter-property-note-location
(org-noter--pretty-print-location location))))))))
;; Add a hook for pdf-annot
(with-eval-after-load 'pdf-annot
(add-hook 'pdf-annot-activate-handler-functions #'org-noter-pdftools-jump-to-note))
;; If you are working with EPUB files
(use-package nov
:straight t)
;; If you are working with DJVU files
(use-package djvu
:straight t))

The jw-emacs-productivity.el call to provide

Section titled “The jw-emacs-productivity.el call to provide”
(provide 'jw-emacs-productivity)
;;; jw-emacs-development.el --- General development tooling -*- lexical-binding: t; -*-
(use-package tramp
:straight t)
(setq tramp-default-method "ssh")
;; for debugging
(setq tramp-verbose 3)
;; some basic performance enhancements
(setq remote-file-name-inhibit-locks t
tramp-use-scp-direct-remote-copying t
remote-file-name-inhibit-auto-save-visited t)
;; disable backup files for tramp
(add-to-list 'backup-directory-alist
(cons tramp-file-name-regexp nil))
(setq tramp-connection-timeout 10) ;; 10 sec timeout

Guide by this blog post.

(connection-local-set-profile-variables
'remote-direct-async-process
'((tramp-direct-async-process . t)))
(connection-local-set-profiles
'(:application tramp :protocol "scp")
'remote-direct-async-process)
(setq magit-tramp-pipe-stty-settings 'pty)

compile command disables remote ssh connection sharing, which will require you to reenter your password each time you connect. Want to enable this for convienence.

(with-eval-after-load 'tramp
(with-eval-after-load 'compile
(remove-hook 'compilation-mode-hook #'tramp-compile-disable-ssh-controlmaster-options)))

Cache passwords until the end of the emacs session, which is default.

(setq password-cache-expiry nil)
;; Configure TRAMP to use ~/.emacs.d/tmp/ for caching
(let ((tramp-tmp-dir (expand-file-name "tmp/" user-emacs-directory)))
;; Ensure the directory exists
(unless (file-directory-p tramp-tmp-dir)
(make-directory tramp-tmp-dir t))
;; Set TRAMP cache directory
(setq tramp-persistency-file-name (expand-file-name "tramp-cache" tramp-tmp-dir))
;; Set auto-save directory for remote files
(setq tramp-auto-save-directory tramp-tmp-dir)
;; Optional: Set backup directory for remote files to tmp as well
(setq tramp-backup-directory-alist `(("." . ,tramp-tmp-dir))))
;; Enable persistent caching
(setq tramp-cache-read-persistent-data t)
(setq tramp-cache-compress t)
(setq remote-file-name-inhibit-cache nil)
(defun memoize-remote (key cache orig-fn &rest args)
"Memoize a value if the key is a remote path."
(if (and key
(file-remote-p key))
(if-let* ((current (assoc key (symbol-value cache))))
(cdr current)
(let ((current (apply orig-fn args)))
(set cache (cons (cons key current) (symbol-value cache)))
current))
(apply orig-fn args)))
;; Memoize current project
(defvar project-current-cache nil)
(defun memoize-project-current (orig &optional prompt directory)
(memoize-remote (or directory
project-current-directory-override
default-directory)
'project-current-cache orig prompt directory))
(advice-add 'project-current :around #'memoize-project-current)
;; Memoize magit top level
(defvar magit-toplevel-cache nil)
(defun memoize-magit-toplevel (orig &optional directory)
(memoize-remote (or directory default-directory)
'magit-toplevel-cache orig directory))
(advice-add 'magit-toplevel :around #'memoize-magit-toplevel)
;; memoize vc-git-root
(defvar vc-git-root-cache nil)
(defun memoize-vc-git-root (orig file)
(let ((value (memoize-remote (file-name-directory file) 'vc-git-root-cache orig file)))
;; sometimes vc-git-root returns nil even when there is a root there
(when (null (cdr (car vc-git-root-cache)))
(setq vc-git-root-cache (cdr vc-git-root-cache)))
value))
(advice-add 'vc-git-root :around #'memoize-vc-git-root)
;; memoize all git candidates in the current project
(defvar $counsel-git-cands-cache nil)
(defun $memoize-counsel-git-cands (orig dir)
($memoize-remote (magit-toplevel dir) '$counsel-git-cands-cache orig dir))
(advice-add 'counsel-git-cands :around #'$memoize-counsel-git-cands)
;; Optional: Function to clear cache when needed
(defun jw/clear-tramp-cache ()
"Clear TRAMP cache files in ~/.emacs.d/tmp/"
(interactive)
(let ((cache-file tramp-persistency-file-name))
(when (file-exists-p cache-file)
(delete-file cache-file)
(message "TRAMP cache cleared"))))
(require 'project)

Set the project paths, but currently the code below only works for emacs 30+.

(defun jw/project-prompter ()
(read-file-name "Select a project folder:"
"~/Core/Otzar/projects/projects__code/"
nil
nil
nil
#'file-directory-p))
(setq project-prompter #'jw/project-prompter)

show-paren-mode allows one to see matching pairs of parentheses and other characters. When point is on the opening character of one of the paired characters, the other is highlighted. When the point is after the closing character of one of the paired characters, the other is highlighted.

(show-paren-mode 1)

To visualize all delimiters,

rainbow-delimiters is useful in programming modes because it colorizes nested parentheses and brackets according to their nesting depth. This makes it a lot easier to visually match parentheses in Emacs Lisp code without having to count them yourself.

(use-package rainbow-delimiters
:hook (prog-mode . rainbow-delimiters-mode))

electric-pair-mode will auto pair delimiters for you. One issue with the auto pairing is the < character in org-mode. The following hook to the enabling of electric-pair-mode aims to solve the issue when in org-mode.

(electric-pair-mode t)
(add-hook 'org-mode-hook (lambda ()
(setq-local electric-pair-inhibit-predicate
`(lambda (c)
(if (char-equal c ?<) t (,electric-pair-inhibit-predicate c))))))

evil-surround emulates surround.vim. For usage instructions visit evil-surround

(use-package evil-surround
:straight t
:config
(global-evil-surround-mode 1))

For logging keys,

command-log-mode is useful for displaying a panel showing each key binding you use in a panel on the right side of the frame. Great for live streams and screencasts!

(use-package command-log-mode)

To activate command-log-mode you must first run M-x global-command-log-mode to have command-log-mode in every buffer and then run M-x clm/toggle-command-log-buffer to have the buffer be displayed.

The jw-emacs-development.el call to provide

Section titled “The jw-emacs-development.el call to provide”
(provide 'jw-emacs-development)

There is a user option in my setup to load this module (The init.el option to enable which-key).

When the which-key-mode is enabled, any incomplete key sequence will produce a popup at the lower part of the Emacs frame showing keys that complete the current sequence together with the name of the command they are invoking.

;;; jw-emacs-which-key.el --- which-key configuration -*- lexical-binding: t; -*-
(use-package which-key
:straight t
:hook (after-init . which-key-mode)
:config
(setq which-key-separator " ")
(setq which-key-prefix-prefix "... ")
(setq which-key-max-display-columns 3)
(setq which-key-idle-delay 1.5)
(setq which-key-idle-secondary-delay 0.25)
(setq which-key-add-column-padding 1)
(setq which-key-max-description-length 40))
(provide 'jw-emacs-which-key)

Set language sources for treesit.

;;; jw-emacs-langs.el --- Language and tree-sitter configuration -*- lexical-binding: t; -*-
(setq treesit-language-source-alist
'((typescript . ("https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src"))
(tsx . ("https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src"))
(python . ("https://github.com/tree-sitter/tree-sitter-python"))
(json "https://github.com/tree-sitter/tree-sitter-json")
(css "https://github.com/tree-sitter/tree-sitter-css")
(html "https://github.com/tree-sitter/tree-sitter-html")
(yaml "https://github.com/ikatyang/tree-sitter-yaml")
(toml "https://github.com/tree-sitter/tree-sitter-toml")
(make "https://github.com/alemuller/tree-sitter-make")
(markdown "https://github.com/ikatyang/tree-sitter-markdown")
(elisp "https://github.com/Wilfred/tree-sitter-elisp")
(cmake "https://github.com/uyha/tree-sitter-cmake")
(c "https://github.com/tree-sitter/tree-sitter-c")
(cpp "https://github.com/tree-sitter/tree-sitter-cpp")
(r "https://github.com/r-lib/tree-sitter-r")
(bash "https://github.com/tree-sitter/tree-sitter-bash")
))
;; Install any missing grammars. Guard each one individually:
;; `treesit-install-language-grammar' signals an error when a grammar cannot
;; be fetched or compiled, which would abort the whole loop and leave later
;; grammars (e.g. `bash', the last entry) uninstalled. Wrapping each call so
;; one bad grammar cannot block the rest.
(dolist (source treesit-language-source-alist)
(let ((lang (car source)))
(unless (treesit-language-available-p lang)
(condition-case err
(treesit-install-language-grammar lang)
(error
(message "Could not install tree-sitter grammar for `%s': %s"
lang (error-message-string err)))))))
(add-to-list 'auto-mode-alist '("\\.ts\\'" . typescript-ts-mode))
(add-to-list 'auto-mode-alist '("\\.tsx\\'" . tsx-ts-mode))
(add-to-list 'auto-mode-alist '("\\.cpp\\'" . c++-ts-mode))
(add-to-list 'auto-mode-alist '("\\.c\\'" . c-ts-mode))
(add-to-list 'auto-mode-alist '("\\.mdx\\'" . markdown-mode))
(add-to-list 'major-mode-remap-alist '(
(python-mode . python-ts-mode)
(json-mode . json-ts-mode)
(css-mode . css-ts-mode)
(bash-mode . bash-ts-mode)
(yaml-mode . yaml-ts-mode)
(c++-mode . c++-ts-mode)
(c-mode . c-ts-mode)
))

The issue with the built in treesit.el is that it does not auto default to which language server. In addition if you need to install you will have to input the url yourself. This package is here to automate the process.

(use-package treesit-auto
:straight t
:custom
(treesit-auto-install 'prompt)
:config
(treesit-auto-add-to-auto-mode-alist 'all)
(global-treesit-auto-mode))

Auto install grammars when missing

(setq treesit-auto-install 'prompt)
(use-package auctex
:straight t
:defer t
:init
(setq TeX-auto-save t)
(setq TeX-parse-self t)
(setq TeX-PDF-mode t)
:config
(setq TeX-engine 'default)
(setq TeX-output-dir "tmp/")
(setq LaTeX-output-directory "tmp/")
(when (eq system-type 'darwin)
(setq TeX-view-program-list
'(("Preview.app" "open -a Preview.app %o")
("Skim" "open -a Skim.app %o")
("displayline" "displayline -g -b %n %o %b")
("open" "open %o")))
(setq TeX-view-program-selection '((output-pdf "Skim"))))
(unless (eq system-type 'darwin)
(setq TeX-view-program-selection '((output-pdf "PDF Tools"))))
(setq TeX-command-default "LaTeX")
(add-hook 'TeX-after-compilation-finished-functions
#'TeX-revert-document-buffer)
(add-hook 'LaTeX-mode-hook #'visual-line-mode)
(add-hook 'LaTeX-mode-hook #'flyspell-mode)
(add-hook 'LaTeX-mode-hook #'LaTeX-math-mode)
(add-hook 'LaTeX-mode-hook #'turn-on-reftex)
(setq reftex-plug-into-AUCTeX t))

Saving a .tex file does not compile it; project Makefiles own the build.

C-c C-a (TeX-command-run-all) remains available when an ad-hoc AUCTeX compile is useful. On macOS, that manual command opens the result in Skim.

Configuring pythone envs with conda.

(use-package conda
:straight t
:config
(setq conda-anaconda-home (expand-file-name "/opt/homebrew/Caskroom/miniconda/base/"))
(setq conda-env-home-directory (expand-file-name "/opt/homebrew/Caskroom/miniconda/base/envs/"))
(conda-env-autoactivate-mode t))

Python formatter configuration.

(use-package python-black
:demand t
:after python
:hook (python-ts-mode . python-black-on-save-mode))

R syntax and REPL integration through ESS.

(use-package ess
:straight t
:mode (("\\.R\\'" . ess-r-mode)
("\\.r\\'" . ess-r-mode)))

I used the following guide from medium for this configuration.

;; WEB MODE
(use-package web-mode
:straight t)
;; astro
;; ASTRO
(define-derived-mode astro-mode web-mode "astro")
(setq auto-mode-alist
(append '((".*\\.astro\\'" . astro-mode))
auto-mode-alist))

Now set the config in eglot.

(use-package pandoc-mode
:straight t
:hook (markdown-mode . pandoc-mode))

Download rust-mode.

(use-package rust-mode
:straight t
:mode "\\.rs\\'"
:config
(add-to-list 'auto-mode-alist '("\\.rs\\'" . rust-mode)))

Setting up rust-mode.

Custom function to find rust analyzer.

(defun jw/find-rust-analyzer ()
(or (executable-find "rust-analyzer")
(expand-file-name "~/.cargo/bin/rust-analyzer")))

Typescript support is done through treesit.el, which is now native to emacs as of v29.

Therefore all of the typescript will be using the tree-sitter equivalent.

;; (use-package typescript-mode
;; :ensure t
;; :mode "\\.ts\\'")

Install json-mode

;; (use-package json-mode
;; :ensure t
;; :mode "\\.json\\'")

Make sure you have the necessary packages installed.

(use-package apheleia
:straight t
:config
(setf (alist-get 'prettier-json apheleia-formatters)
'("prettier" "--stdin-filepath" filepath))
;; R formatter via styler (requires `install.packages("styler")` in R)
(setf (alist-get 'r-styler apheleia-formatters)
'("Rscript" "--vanilla" "-e"
"con<-file('stdin');txt<-readLines(con,warn=FALSE);close(con);cat(styler::style_text(txt),sep='\\n')"))
;; Map json-ts-mode to the prettier-json formatter
(setf (alist-get 'json-ts-mode apheleia-mode-alist)
'(prettier-json))
(setf (alist-get 'ess-r-mode apheleia-mode-alist)
'(r-styler))
(add-to-list 'apheleia-mode-alist '(tsx-ts-mode . prettier))
(add-to-list 'apheleia-mode-alist '(typescript-ts-mode . prettier))
(add-to-list 'apheleia-mode-alist '(c++-ts-mode . clang-format))
(add-to-list 'apheleia-mode-alist '(c-ts-mode . clang-format))
(apheleia-global-mode +1))
;; Dynamic server program functions
(defun jw/python-lsp-program (&optional _interactive)
"Get Python LSP program."
(if (file-remote-p default-directory)
'("/home/jozhw/bin/pylsp-wrapper")
'("/opt/homebrew/Caskroom/miniconda/base/bin/pyright-langserver" "--stdio")))
(defun jw/rust-lsp-program (&optional _interactive)
"Get Rust LSP program."
(list (jw/find-rust-analyzer)))
(defun jw/clangd-lsp-program (&optional _interactive)
"Get clangd LSP program."
'("clangd"))
(defun jw/typescript-lsp-program (&optional _interactive)
"Get TypeScript LSP program."
'("typescript-language-server" "--stdio"))
(defun jw/marksman-lsp-program (&optional _interactive)
"Get Marksman LSP program."
'("marksman"))
(defun jw/astro-lsp-program (&optional _interactive)
"Get Astro LSP program."
'("astro-ls" "--stdio" :initializationOptions (:typescript (:tsdk "./node_modules/typescript/lib"))))
(defun jw/tex-lsp-program (&optional _interactive)
"Get latex lsp program"
'("texlab")
)
(defun jw/r-lsp-program (&optional _interactive)
"Get R LSP program."
'("R" "--slave" "-e" "languageserver::run()"))

Add to eglot server list and setup hook after eglot is loaded.

;; Enhanced eglot configuration
(with-eval-after-load 'eglot
(setq eglot-prefer-local-server t)
;; undo elgot modifications of completion-category-defaults
(setq completion-category-defaults nil)
(setq eglot-connect-timeout 120)
;; Use function symbols - eglot will call these functions to get the command
(add-to-list 'eglot-server-programs
'(python-ts-mode . jw/python-lsp-program))
(add-to-list 'eglot-server-programs
'(rust-mode . jw/rust-lsp-program))
(add-to-list 'eglot-server-programs
'((c++-ts-mode c-ts-mode) . jw/clangd-lsp-program))
(add-to-list 'eglot-server-programs
'(typescript-ts-mode . jw/typescript-lsp-program))
(add-to-list 'eglot-server-programs
'(tsx-ts-mode . jw/typescript-lsp-program))
(add-to-list 'eglot-server-programs
'(markdown-mode . jw/marksman-lsp-program))
(add-to-list 'eglot-server-programs
'((latex-mode tex-mode LaTeX-mode) . jw/tex-lsp-program))
(add-to-list 'eglot-server-programs
'(ess-r-mode . jw/r-lsp-program))
(add-to-list 'eglot-server-programs
'(astro-mode . jw/astro-lsp-program)))

Function to start eglot.

;; Function to start eglot
(defun jw/maybe-start-eglot ()
"Start eglot if current mode is supported and file is not remote."
(when (and (not (file-remote-p default-directory))
(or (derived-mode-p 'python-mode)
(derived-mode-p 'python-ts-mode)
(derived-mode-p 'rust-mode)
(derived-mode-p 'tex-mode)
(derived-mode-p 'c-ts-mode)
(derived-mode-p 'c++-ts-mode)
(derived-mode-p 'typescript-ts-mode)
(derived-mode-p 'tsx-ts-mode)
(derived-mode-p 'markdown-mode)
(derived-mode-p 'astro-mode)
(derived-mode-p 'ess-r-mode)))
(eglot-ensure)))
;; Helper function to restart eglot in current buffer
(defun jw/restart-eglot ()
"Restart eglot in current buffer."
(interactive)
(when (eglot-current-server)
(eglot-shutdown (eglot-current-server))
(eglot-ensure)))

Add the hook to auto start eglot depending on configured language.

(add-hook 'python-ts-mode-hook #'jw/maybe-start-eglot)
(add-hook 'rust-mode-hook #'jw/maybe-start-eglot)
(add-hook 'c-ts-mode-hook #'jw/maybe-start-eglot)
(add-hook 'c++-ts-mode-hook #'jw/maybe-start-eglot)
(add-hook 'typescript-ts-mode-hook #'jw/maybe-start-eglot)
(add-hook 'tsx-ts-mode-hook #'jw/maybe-start-eglot)
(add-hook 'markdown-mode-hook #'jw/maybe-start-eglot)
(add-hook 'astro-mode-hook #'jw/maybe-start-eglot)
(add-hook 'tex-mode-hook #'jw/maybe-start-eglot)
(add-hook 'ess-r-mode-hook #'jw/maybe-start-eglot)

For debuggin,

dape depends on jsonrpc. Pin it to Emacs’ built-in copy instead of letting straight clone GNU ELPA’s older one, which shadows the built-in jsonrpc.el that eglot.el needs and breaks completion.

(straight-use-package '(jsonrpc :type built-in))
(use-package dape
:straight t
;; :preface
;; By default dape shares the same keybinding prefix as `gud'
;; If you do not want to use any prefix, set it to nil.
;; (setq dape-key-prefix "\C-x\C-a")
:hook
;; Save breakpoints on quit
(kill-emacs . dape-breakpoint-save)
;; Load breakpoints on startup
;; (after-init . dape-breakpoint-load)
:config
;; Turn on global bindings for setting breakpoints with mouse
;; (dape-breakpoint-global-mode)
;; Info buffers to the right
(setq dape-buffer-window-arrangement 'right)
;; Info buffers like gud (gdb-mi)
(setq dape-buffer-window-arrangement 'gud)
(setq dape-info-hide-mode-line nil)
;; Pulse source line (performance hit)
(add-hook 'dape-display-source-hook 'pulse-momentary-highlight-one-line)
;; Showing inlay hints
(setq dape-inlay-hints t)
;; Save buffers on startup, useful for interpreted languages
(add-hook 'dape-start-hook (lambda () (save-some-buffers t t)))
;; Kill compile buffer on build success
(add-hook 'dape-compile-hook 'kill-buffer)
;; Projectile users
;; (setq dape-cwd-function 'projectile-project-root)
)
;; Enable repeat mode for more ergonomic `dape' use
(use-package repeat
:config
(repeat-mode))
;; for remote configs
(with-eval-after-load 'tramp
(require 'tramp-sh)
(setq tramp-own-remote-path '("/bin" "/usr/bin" "/usr/local/bin"))
(add-to-list 'tramp-remote-path 'tramp-own-remote-path))
(provide 'jw-emacs-langs)