doomemacs/lisp/doom-editor.el
Henrik Lissner 0d35240e70
refactor: large file optimizations
Fixes the large-file detection and rewrites it to lean more on the
built-in `so-long` library to detect and handle "large" files (whose
line count exceeds a given threshold). This removes the various
`doom-large-*` variables, replacing them with
`doom-file-lines-threshold-alist`, which defaults to 15-25k lines for
all modes, depending in the presence of IGC or native-comp.

I also no longer do this large file detection in 28 or older, because
it's not worth the trouble to maintain. Users that care about
performance should be on 30+ anyway.
2026-03-20 21:52:52 -04:00

572 lines
24 KiB
EmacsLisp

;;; doom-editor.el --- defaults for text editing in Doom -*- lexical-binding: t; -*-
;;; Commentary:
;;; Code:
(defvar doom-file-lines-threshold-alist
`(("." . ,(cond ((featurep 'igc) 25000)
((featurep 'native-compile) 20000)
(15000))))
"An alist mapping regexps (like `auto-mode-alist') to line number thresholds.
If a file is opened and discovered to have more lines than this, Doom enables
`so-long-minor-mode' to prevent Emacs from hanging, crashing, or becoming
unusably slow, by disabling non-essential functionality.
Used by `doom-so-long-p'.")
;;
;;; File handling
;; Resolve symlinks when opening files, so that any operations are conducted
;; from the file's true directory (like `find-file').
(setq find-file-visit-truename t
vc-follow-symlinks t)
;; Disable the warning "X and Y are the same file". It's fine to ignore this
;; warning as it will redirect you to the existing buffer anyway.
(setq find-file-suppress-same-file-warnings t)
;; Create missing directories when we open a file that doesn't exist under a
;; directory tree that may not exist.
(add-hook! 'find-file-not-found-functions
(defun doom-create-missing-directories-h ()
"Automatically create missing directories when creating new files."
(unless (file-remote-p buffer-file-name)
(let ((parent-directory (file-name-directory buffer-file-name)))
(and (not (file-directory-p parent-directory))
(y-or-n-p (format "Directory `%s' does not exist! Create it?"
parent-directory))
(progn (make-directory parent-directory 'parents)
t))))))
;; Don't generate backups or lockfiles. While auto-save maintains a copy so long
;; as a buffer is unsaved, backups create copies once, when the file is first
;; written, and never again until it is killed and reopened. This is better
;; suited to version control, and I don't want world-readable copies of
;; potentially sensitive material floating around our filesystem.
(setq create-lockfiles nil
make-backup-files nil
;; But in case the user does enable it, some sensible defaults:
version-control t ; number each backup file
backup-by-copying t ; instead of renaming current file (clobbers links)
delete-old-versions t ; clean up after itself
kept-old-versions 5
kept-new-versions 5
backup-directory-alist `(("." . ,(file-name-concat doom-profile-cache-dir "backup/")))
tramp-backup-directory-alist backup-directory-alist)
;; But turn on auto-save, so we have a fallback in case of crashes or lost data.
;; Use `recover-file' or `recover-session' to recover them.
(setq auto-save-default t
;; Don't auto-disable auto-save after deleting big chunks. This defeats
;; the purpose of a failsafe. This adds the risk of losing the data we
;; just deleted, but I believe that's VCS's jurisdiction, not ours.
auto-save-include-big-deletions t
;; Keep it out of `doom-emacs-dir' or the local directory.
auto-save-list-file-prefix (file-name-concat doom-profile-cache-dir "autosave/")
;; This resolves two issue while ensuring auto-save files are still
;; reasonably recognizable at a glance:
;;
;; 1. Emacs generates long file paths for its auto-save files; long =
;; `auto-save-list-file-prefix' + `buffer-file-name'. If too long, some
;; filesystems (*cough*Windows) will murder your family. `sha1'
;; compresses the path into a ~40 character hash (Emacs 28+ only)!
;; 2. The default transform rule writes TRAMP auto-save files to
;; `temporary-file-directory', which TRAMP doesn't like! It'll prompt
;; you about it every time an auto-save file is written, unless
;; `tramp-allow-unsafe-temporary-files' is set. A more sensible default
;; transform is better:
auto-save-file-name-transforms
`(("\\`/[^/]*:\\([^/]*/\\)*\\([^/]*\\)\\'"
,(file-name-concat auto-save-list-file-prefix "tramp-\\2-") sha1)
("\\`/\\([^/]+/\\)*\\([^/]+\\)\\'"
,(file-name-concat auto-save-list-file-prefix "\\2-") sha1)))
(add-hook! 'auto-save-hook
(defun doom-ensure-auto-save-prefix-exists-h ()
(with-file-modes #o700
(make-directory auto-save-list-file-prefix t))))
(add-hook! 'after-save-hook
(defun doom-guess-mode-h ()
"Guess major mode when saving a file in `fundamental-mode'.
Likely, something has changed since the buffer was opened. e.g. A shebang line
or file path may exist now."
(when (eq major-mode 'fundamental-mode)
(let ((buffer (or (buffer-base-buffer) (current-buffer))))
(and (buffer-file-name buffer)
(eq buffer (window-buffer (selected-window))) ; only visible buffers
(set-auto-mode)
(not (eq major-mode 'fundamental-mode)))))))
(defadvice! doom--shut-up-autosave-a (fn &rest args)
"If a file has autosaved data, `after-find-file' will pause for 1 second to
tell you about it. Very annoying. This prevents that."
:around #'after-find-file
(letf! ((#'sit-for #'ignore))
(apply fn args)))
;; HACK: Make sure backup files (like undo-tree's) don't have ridiculously long
;; file names that some filesystems will refuse.
;; REVIEW: PR this upstream, like they have with the UNIQUIFY argument in
;; `auto-save-file-name-transforms' entries.
(defadvice! doom-make-hashed-backup-file-name-a (fn file)
"A few places use the backup file name so paths don't get too long."
:around #'make-backup-file-name-1
(let ((alist backup-directory-alist)
backup-directory)
(while alist
(let ((elt (car alist)))
(if (string-match (car elt) file)
(setq backup-directory (cdr elt)
alist nil)
(setq alist (cdr alist)))))
(let ((file (funcall fn file)))
(if (or (null backup-directory)
(not (file-name-absolute-p backup-directory)))
file
(expand-file-name (sha1 (file-name-nondirectory file))
(file-name-directory file))))))
;;
;;; Formatting
;; Favor spaces over tabs. Pls dun h8, but I think spaces (and 4 of them) is a
;; more consistent default than 8-space tabs. It can be changed on a per-mode
;; basis anyway (and is, where tabs are the canonical style, like `go-mode').
(setq-default indent-tabs-mode nil
tab-width 4)
;; Only indent the line when at BOL or in a line's indentation. Anywhere else,
;; insert literal indentation.
(setq-default tab-always-indent nil)
;; Make `tabify' and `untabify' only affect indentation. Not tabs/spaces in the
;; middle of a line.
(setq tabify-regexp "^\t* [ \t]+")
;; An archaic default in the age of widescreen 4k displays? I disagree. We still
;; frequently split our terminals and editor frames, or have them side-by-side,
;; using up more of that newly available horizontal real-estate.
(setq-default fill-column 80)
;; Continue wrapped words at whitespace, rather than in the middle of a word.
(setq-default word-wrap t)
;; ...but don't do any wrapping by default. It's expensive. Enable
;; `visual-line-mode' if you want soft line-wrapping. `auto-fill-mode' for hard
;; line-wrapping.
(setq-default truncate-lines t)
;; If enabled (and `truncate-lines' was disabled), soft wrapping no longer
;; occurs when that window is less than `truncate-partial-width-windows'
;; characters wide. We don't need this, and it's extra work for Emacs otherwise,
;; so off it goes.
(setq truncate-partial-width-windows nil)
;; This was a widespread practice in the days of typewriters. I actually prefer
;; it when writing prose with monospace fonts, but it is obsolete otherwise.
(setq sentence-end-double-space nil)
;; The POSIX standard defines a line is "a sequence of zero or more non-newline
;; characters followed by a terminating newline", so files should end in a
;; newline. Windows doesn't respect this (because it's Windows), but we should,
;; since programmers' tools tend to be POSIX compliant (and no big deal if not).
(setq require-final-newline t)
;; Default to soft line-wrapping in text modes. It is more sensibile for text
;; modes, even if hard wrapping is more performant.
(add-hook 'text-mode-hook #'visual-line-mode)
;;
;;; Clipboard / kill-ring
;; Cull duplicates in the kill ring to reduce bloat and make the kill ring
;; easier to peruse (with `counsel-yank-pop' or `helm-show-kill-ring'.
(setq kill-do-not-save-duplicates t)
;;
;;; Extra file extensions to support
(add-to-list 'auto-mode-alist '("/LICENSE\\'" . text-mode))
(add-to-list 'auto-mode-alist '("rc\\'" . conf-mode) 'append)
;;
;;; Built-in plugins
(use-package! autorevert
;; revert buffers when their files/state have changed
:hook (doom-first-file . doom-auto-revert-mode)
:config
(setq auto-revert-verbose t ; let us know when it happens
auto-revert-use-notify nil
auto-revert-stop-on-user-input nil
;; Only prompts for confirmation when buffer is unsaved.
revert-without-query (list "."))
;; PERF: `auto-revert-mode' and `global-auto-revert-mode' would, normally,
;; abuse the heck out of file watchers _or_ aggressively poll your buffer
;; list every X seconds. Too many watchers can grind Emacs to a halt if you
;; preform expensive or batch processes on files outside of Emacs (e.g.
;; their mtime changes), and polling your buffer list is terribly
;; inefficient as your buffer list grows into the hundreds.
;;
;; Doom does this lazily instead. i.e. All visible buffers are reverted
;; immediately when a) a file is saved or b) Emacs is refocused (after using
;; another app). Meanwhile, buried buffers are reverted only when they are
;; switched to. This way, Emacs only ever has to operate on, at minimum, a
;; single buffer and, at maximum, ~10 x F buffers, where F = number of open
;; frames (after all, when do you ever have more than 10 windows in any
;; single frame?).
(define-minor-mode doom-auto-revert-mode
"A more performant alternative to `global-auto-revert-mode'."
:global t
:group 'doom
(when global-auto-revert-mode
(setq doom-auto-revert-mode nil))
(let ((fn (if doom-auto-revert-mode #'add-hook #'remove-hook)))
(funcall fn 'doom-switch-buffer-hook #'doom-auto-revert-buffer-h)
(funcall fn 'doom-switch-window-hook #'doom-auto-revert-buffer-h)
(funcall fn 'doom-switch-frame-hook #'doom-auto-revert-buffers-h)
(funcall fn 'after-save-hook #'doom-auto-revert-buffers-h)))
(defun doom-auto-revert-buffer-h ()
"Auto revert current buffer, if necessary."
(unless (or auto-revert-mode
(active-minibuffer-window)
(and buffer-file-name
auto-revert-remote-files
(file-remote-p buffer-file-name nil t)))
(let ((auto-revert-mode t))
(auto-revert-handler))))
(defun doom-auto-revert-buffers-h ()
"Auto revert stale buffers in visible windows, if necessary."
(dolist (buf (doom-visible-buffers))
(with-current-buffer buf
(doom-auto-revert-buffer-h)))))
;;;###package bookmark
(setq bookmark-default-file (file-name-concat doom-profile-data-dir "bookmarks"))
(use-package! recentf
;; Keep track of recently opened files
:defer-incrementally easymenu tree-widget timer
:hook (doom-first-file . recentf-mode)
:commands recentf-open-files
:custom (recentf-save-file (file-name-concat doom-profile-cache-dir "recentf"))
:config
(setq recentf-max-saved-items 200) ; default is 20
;; Anything in runtime folders
(add-to-list 'recentf-exclude
(concat "^" (regexp-quote (or (getenv "XDG_RUNTIME_DIR")
"/run"))))
;; PERF: Text properties inflate the size of recentf's files, and there is no
;; reason to persist them (must be first in `recentf-filename-handlers'!)
(add-to-list 'recentf-filename-handlers #'substring-no-properties)
;; UX: Reorder the recent files list by frecency (i.e. every time you touch a
;; buffer, bump it to the top of the list).
(add-hook! '(doom-switch-window-hook write-file-functions)
(defun doom--recentf-touch-buffer-h ()
"Bump file in recent file list when it is switched or written to."
(when buffer-file-name
(recentf-add-file buffer-file-name))
;; Return nil for `write-file-functions'
nil))
(add-hook! 'dired-mode-hook
(defun doom--recentf-add-dired-directory-h ()
"Add dired directories to recentf file list."
(recentf-add-file default-directory)))
;; The most sensible time to clean up your recent files list is when you quit
;; Emacs (unless this is a long-running daemon session).
(setq recentf-auto-cleanup (if (daemonp) 300 'never))
;; Use a negative depth value because we need `recentf-cleanup' to run before
;; `recentf-save-list' to be effective, which `recentf-mode' will only add to
;; `kill-emacs-hook' once it is enabled.
(add-hook 'kill-emacs-hook #'recentf-cleanup -50)
;; Otherwise `load-file' calls in `recentf-load-list' pollute *Messages*
(advice-add #'recentf-load-list :around #'doom-shut-up-a))
(use-package! savehist
;; persist variables across sessions
:defer-incrementally custom
:hook (doom-first-input . savehist-mode)
:custom (savehist-file (file-name-concat doom-profile-cache-dir "savehist"))
:config
(setq savehist-save-minibuffer-history t
savehist-autosave-interval nil ; save on kill only
savehist-additional-variables
'(kill-ring ; persist clipboard
register-alist ; persist macros
mark-ring global-mark-ring ; persist marks
search-ring regexp-search-ring)) ; persist searches
(add-hook! 'savehist-save-hook
(defun doom-savehist-unpropertize-variables-h ()
"Remove text properties from `kill-ring' to reduce savehist cache size."
(setq kill-ring
(mapcar #'substring-no-properties
(cl-remove-if-not #'stringp kill-ring))
register-alist
(cl-loop for (reg . item) in register-alist
if (stringp item)
collect (cons reg (substring-no-properties item))
else collect (cons reg item))))
(defun doom-savehist-remove-unprintable-registers-h ()
"Remove unwriteable registers (e.g. containing window configurations).
Otherwise, `savehist' would discard `register-alist' entirely if we don't omit
the unwritable tidbits."
;; Save new value in the temp buffer savehist is running
;; `savehist-save-hook' in. We don't want to actually remove the
;; unserializable registers in the current session!
(setq-local register-alist
(cl-remove-if-not #'savehist-printable register-alist)))))
(use-package! saveplace
;; persistent point location in buffers
:hook (doom-first-file . save-place-mode)
:custom (save-place-file (file-name-concat doom-profile-cache-dir "saveplace"))
:config
(defadvice! doom--recenter-on-load-saveplace-a (&rest _)
"Recenter on cursor when loading a saved place."
:after-while #'save-place-find-file-hook
(if buffer-file-name (ignore-errors (recenter))))
(defadvice! doom--inhibit-saveplace-in-long-files-a (fn &rest args)
:around #'save-place-to-alist
(unless (bound-and-true-p so-long-minor-mode)
(apply fn args)))
(defadvice! doom--inhibit-saveplace-if-point-not-at-bol-a (&rest _)
"If something else has moved point, don't try to move it again."
:before-while #'save-place-find-file-hook
(bobp))
(defadvice! doom--dont-prettify-saveplace-cache-a (fn)
"`save-place-alist-to-file' uses `pp' to prettify the contents of its cache.
`pp' can be expensive for longer lists, and there's no reason to prettify cache
files, so this replace calls to `pp' with the much faster `prin1'."
:around #'save-place-alist-to-file
(letf! ((#'pp #'prin1)) (funcall fn))))
(use-package! server
:when (display-graphic-p)
:after-call doom-first-input-hook doom-first-file-hook
:defer 1
:config
(when-let* ((name (getenv "EMACS_SERVER_NAME")))
(setq server-name name))
(unless (server-running-p)
(server-start)))
;;
;;; Packages
(use-package! better-jumper
:hook (doom-first-input . better-jumper-mode)
:commands doom-set-jump-a doom-set-jump-maybe-a doom-set-jump-h
:init
(global-set-key [remap evil-jump-forward] #'better-jumper-jump-forward)
(global-set-key [remap evil-jump-backward] #'better-jumper-jump-backward)
(global-set-key [remap xref-pop-marker-stack] #'better-jumper-jump-backward)
(global-set-key [remap xref-go-back] #'better-jumper-jump-backward)
(global-set-key [remap xref-go-forward] #'better-jumper-jump-forward)
:config
(defun doom-set-jump-a (fn &rest args)
"Set a jump point and ensure fn doesn't set any new jump points."
(better-jumper-set-jump (if (markerp (car args)) (car args)))
(let ((evil--jumps-jumping t)
(better-jumper--jumping t))
(apply fn args)))
(defun doom-set-jump-maybe-a (fn &rest args)
"Set a jump point if fn actually moves the point."
(let ((origin (point-marker))
(result
(let* ((evil--jumps-jumping t)
(better-jumper--jumping t))
(apply fn args)))
(dest (point-marker)))
(unless (equal origin dest)
(with-current-buffer (marker-buffer origin)
(better-jumper-set-jump
(if (markerp (car args))
(car args)
origin))))
(set-marker origin nil)
(set-marker dest nil)
result))
(defun doom-set-jump-h ()
"Run `better-jumper-set-jump' but return nil, for short-circuiting hooks."
(when (get-buffer-window)
(better-jumper-set-jump))
nil)
;; Creates a jump point before killing a buffer. This allows you to undo
;; killing a buffer easily (only works with file buffers though; it's not
;; possible to resurrect special buffers).
;;
;; I'm not advising `kill-buffer' because I only want this to affect
;; interactively killed buffers.
(add-hook 'kill-buffer-hook #'doom-set-jump-h)
;; Manual support for specific commands:
(advice-add #'outline-up-heading :around #'doom-set-jump-a)
(advice-add #'imenu :around #'doom-set-jump-a))
(use-package! smartparens
;; Auto-close delimiters and blocks as you type. It's more powerful than that,
;; but that is all Doom uses it for.
:hook (doom-first-buffer . smartparens-global-mode)
:commands sp-pair sp-local-pair sp-with-modes sp-point-in-comment sp-point-in-string
:config
;; smartparens recognizes `slime-mrepl-mode', but not `sly-mrepl-mode', so...
(add-to-list 'sp-lisp-modes 'sly-mrepl-mode)
;; Load default smartparens rules for various languages
(require 'smartparens-config)
;; Overlays are too distracting and not terribly helpful. show-parens does
;; this for us already (and is faster), so...
(setq sp-highlight-pair-overlay nil
sp-highlight-wrap-overlay nil
sp-highlight-wrap-tag-overlay nil)
(with-eval-after-load 'evil
;; But if someone does want overlays enabled, evil users will be stricken
;; with an off-by-one issue where smartparens assumes you're outside the
;; pair when you're really at the last character in insert mode. We must
;; correct this vile injustice.
(setq sp-show-pair-from-inside t)
;; ...and stay highlighted until we've truly escaped the pair!
(setq sp-cancel-autoskip-on-backward-movement nil)
;; Smartparens conditional binds a key to C-g when sp overlays are active
;; (even if they're invisible). This disruptively changes the behavior of
;; C-g in insert mode, requiring two presses of the key to exit insert mode.
;; I don't see the point of this keybind, so...
(setq sp-pair-overlay-keymap (make-sparse-keymap)))
;; The default is 100, because smartparen's scans are relatively expensive
;; (especially with large pair lists for some modes), we reduce it, as a
;; better compromise between performance and accuracy.
(setq sp-max-prefix-length 25)
;; No pair has any business being longer than 4 characters; if they must, set
;; it buffer-locally. It's less work for smartparens.
(setq sp-max-pair-length 4)
;; Silence some harmless but annoying echo-area spam
(dolist (key '(:unmatched-expression :no-matching-tag))
(setf (alist-get key sp-message-alist) nil))
(add-hook! 'eval-expression-minibuffer-setup-hook
(defun doom-init-smartparens-in-eval-expression-h ()
"Enable `smartparens-mode' in the minibuffer for `eval-expression'.
This includes everything that calls `read--expression', e.g.
`edebug-eval-expression' Only enable it if
`smartparens-global-mode' is on."
(when smartparens-global-mode (smartparens-mode +1))))
(add-hook! 'minibuffer-setup-hook
(defun doom-init-smartparens-in-minibuffer-maybe-h ()
"Enable `smartparens' for non-`eval-expression' commands.
Only enable `smartparens-mode' if `smartparens-global-mode' is
on."
(when (and smartparens-global-mode (memq this-command '(evil-ex)))
(smartparens-mode +1))))
;; You're likely writing lisp in the minibuffer, therefore, disable these
;; quote pairs, which lisps doesn't use for strings:
(sp-local-pair '(minibuffer-mode minibuffer-inactive-mode) "'" nil :actions nil)
(sp-local-pair '(minibuffer-mode minibuffer-inactive-mode) "`" nil :actions nil)
;; Smartparens breaks evil-mode's replace state
(defvar doom-buffer-smartparens-mode nil)
(add-hook! 'evil-replace-state-exit-hook
(defun doom-enable-smartparens-mode-maybe-h ()
(when doom-buffer-smartparens-mode
(turn-on-smartparens-mode)
(kill-local-variable 'doom-buffer-smartparens-mode))))
(add-hook! 'evil-replace-state-entry-hook
(defun doom-disable-smartparens-mode-maybe-h ()
(when smartparens-mode
(setq-local doom-buffer-smartparens-mode t)
(smartparens-mode -1)))))
(use-package! so-long
:when (fboundp 'buffer-line-statistics) ; only 29+
:hook (doom-first-file . global-so-long-mode)
:config
(unless (featurep 'native-compile)
(setq so-long-threshold 5000))
;; HACK: I exploit so-long to implement a "large file" minor mode that
;; activates if a file is too large or has lines whose width exceed
;; `so-long-threshold' (particularly minified files), and disables
;; non-essential functionality to speed Emacs up.
(defun doom-so-long-p ()
"A `so-long-predicate' to determine if the current buffer is too large.
This is determined by the longest line (whether it exceeds `so-long-threshold')
and whether the line count of the buffer exceeds that matching entry in
`doom-file-lines-threshold-alist' (defaulting to 20k lines)."
(unless
;; HACK: Prevent so-long in places where we don't want it, like special
;; buffers (e.g. magit status) or temp buffers.
(or (doom-temp-buffer-p (current-buffer))
(doom-special-buffer-p (current-buffer) t))
(let ((stats (buffer-line-statistics)))
(or (> (cadr stats) so-long-threshold)
(and buffer-file-name
(when-let* ((maxlines
(assoc-default buffer-file-name doom-file-lines-threshold-alist
#'string-match-p)))
(> (car stats) maxlines)))))))
(setq so-long-predicate #'doom-so-long-p
so-long-function #'turn-on-so-long-minor-mode
so-long-revert-function #'turn-off-so-long-minor-mode)
(add-to-list 'so-long-target-modes 'conf-mode)
(add-to-list 'so-long-target-modes 'text-mode)
;; Don't disable syntax highlighting and line numbers, or make the buffer
;; read-only, in `so-long-minor-mode', so we can have a basic editing
;; experience in them, at least. It will remain off in `so-long-mode',
;; however, because long files have a far bigger impact on Emacs performance.
(cl-callf2 delq 'font-lock-mode so-long-minor-modes)
(cl-callf2 delq 'display-line-numbers-mode so-long-minor-modes)
(setf (alist-get 'buffer-read-only so-long-variable-overrides nil t) nil)
;; ...but at least reduce the level of syntax highlighting
(add-to-list 'so-long-variable-overrides '(font-lock-maximum-decoration . 1))
;; ...and insist that save-place not operate in large/long files
(add-to-list 'so-long-variable-overrides '(save-place-alist . nil))
;; But disable everything else that may be unnecessary/expensive for large or
;; wide buffers.
(cl-callf append so-long-minor-modes
'(spell-fu-mode
eldoc-mode
better-jumper-local-mode
ws-butler-mode
auto-composition-mode
undo-tree-mode
highlight-indent-guides-mode
hl-fill-column-mode
;; These are redundant on Emacs 29+
flycheck-mode
smartparens-mode
smartparens-strict-mode)))
(provide 'doom-editor)
;;; doom-editor.el ends here