;;; prog-general --- General Programming Settings -*- lexical-binding: t; coding: utf-8; -*- ;; author: Craig Jennings ;;; Commentary: ;; This module provides general programming functionality not related to a ;; specific programming language, such as code-folding, project management, ;; highlighting symbols, snippets, and whitespace management. ;; ;; Keybinding Scheme: ;; ------------------ ;; The F4–F7 dev block is owned by dev-fkeys.el (global bindings). Per- ;; language modules only set S-F5 / S-F6 overrides for static analysis ;; and debugging. F5 is reserved for the debug ticket. ;; ;; Global (dev-fkeys.el): ;; F4 / C-F4 / M-F4 compile + run dispatcher / compile only / clean + rebuild ;; S-F4 recompile (repeat last) ;; F6 project tests (Phase 1 stopgap; Phase 2 = polyglot dispatcher) ;; F7 coverage report (coverage-core.el) ;; C-; f language-specific formatter ;; ;; Per-language S-modifier overrides: ;; | Key | C | Go | Python | Shell | ;; |------|----------|-------------|--------|------------| ;; | S-F5 | disabled | staticcheck | mypy | shellcheck | ;; | S-F6 | gdb | dlv | pdb | disabled | ;;; Code: (eval-when-compile (defvar code-dir)) (eval-when-compile (defvar projects-dir)) (eval-when-compile (defvar snippets-dir)) (defvar display-line-numbers-type) (defvar outline-minor-mode-map) (defvar projectile-per-project-compilation-buffer) (defvar projectile-switch-project-action) (defvar projectile-command-map) (defvar dired-mode-map) (defvar yas-snippet-dirs) (defvar highlight-indent-guides-auto-enabled) (defvar hl-todo-keyword-faces) (defvar ws-butler-convert-leading-tabs-or-spaces) (declare-function projectile-project-root "projectile") (declare-function projectile-mode "projectile") (declare-function magit-status "magit") (declare-function dired-get-filename "dired") (declare-function global-treesit-auto-mode "treesit-auto") (declare-function treesit-auto-add-to-auto-mode-alist "treesit-auto") (declare-function treesit-auto-recipe-lang "treesit-auto") (declare-function highlight-indent-guides-mode "highlight-indent-guides") ;; Forward declarations for treesit-auto variables (defvar treesit-auto-recipe-list) ;; Forward declarations for functions defined later in this file (declare-function cj/find-project-root-file "prog-general") (declare-function cj/project-switch-actions "prog-general") (declare-function cj/deadgrep--initial-term "prog-general") (declare-function cj/highlight-indent-guides-disable-in-non-prog-modes "prog-general") ;; --------------------- General Programming Mode Settings --------------------- ;; keybindings, minor-modes, and prog-mode settings (defun cj/general-prog-settings () "Keybindings, minor modes, and settings for programming mode." (interactive) (display-line-numbers-mode) ;; show line numbers (setq display-line-numbers-type 'relative) ;; display numbers relative to 'the point' (setq-default display-line-numbers-width 3) ;; 3 characters reserved for line numbers (turn-on-visual-line-mode) ;; word-wrapping (auto-fill-mode) ;; auto wrap at the fill column set (local-set-key (kbd "M-;") 'comment-dwim) ;; comment/uncomment region as appropriate ;; F4–F6 are global, owned by dev-fkeys.el. F5 is reserved for the ;; debug ticket (separate work). ) (add-hook 'prog-mode-hook #'cj/general-prog-settings) (add-hook 'html-mode-hook #'cj/general-prog-settings) (add-hook 'yaml-mode-hook #'cj/general-prog-settings) (add-hook 'toml-mode-hook #'cj/general-prog-settings) ;; --------------------------------- Treesitter -------------------------------- ;; incremental language syntax parser ;; Using Emacs 29+ built-in treesit with treesit-auto for grammar management ;; installs tree-sitter grammars if they're absent (use-package treesit-auto :custom (treesit-auto-install t) ;; (treesit-auto-install 'prompt) ;; optional prompt instead of auto-install :config (require 'cl-lib) ;; Pin Go grammar to v0.19.1 for compatibility with Emacs 30.2 font-lock queries (let* ((go-idx (cl-position-if (lambda (recipe) (eq (treesit-auto-recipe-lang recipe) 'go)) treesit-auto-recipe-list)) (go-recipe (and go-idx (nth go-idx treesit-auto-recipe-list)))) (when go-recipe ;; Directly modify the slot value using aset (struct fields are vectors internally) (aset go-recipe 6 "v0.19.1"))) ; slot 6 is :revision (treesit-auto-add-to-auto-mode-alist 'all) (global-treesit-auto-mode)) ;; -------------------------------- Code Folding ------------------------------- ;; BICYCLE ;; cycle visibility of outline sections and code blocks. (use-package bicycle :after outline :hook (prog-mode . outline-minor-mode) :bind (:map outline-minor-mode-map ("C-" . bicycle-cycle) ;; backtab is shift-tab ("" . bicycle-cycle-global))) ;; --------------------------------- Projectile -------------------------------- ;; project support ;; only discover projects when there's no bookmarks file (defun cj/projectile-schedule-project-discovery () (let ((projectile-bookmark-file (concat user-emacs-directory "/projectile-bookmarks.eld"))) (unless (file-exists-p projectile-bookmark-file) (run-at-time "3" nil 'projectile-discover-projects-in-search-path)))) (use-package projectile :bind-keymap ("C-c p" . projectile-command-map) :bind (:map projectile-command-map ("r" . projectile-replace-regexp) ("t" . cj/open-project-root-todo)) :custom (projectile-auto-discover nil) (projectile-project-search-path `(,code-dir ,projects-dir)) :config (require 'seq) (defun cj/find-project-root-file (regexp) "Return first file in the current Projectile project root matching REGEXP. Match is done against (downcase file) for case-insensitivity. REGEXP must be a string or an rx form." (when-let ((root (projectile-project-root))) (seq-find (lambda (file) (string-match-p (if (stringp regexp) regexp (rx-to-string regexp)) (downcase file))) (directory-files root)))) (defun cj/open-project-root-todo () "Open todo.org in the current Projectile project root. If no such file exists there, display a message." (interactive) (if-let ((root (projectile-project-root))) (let ((file (cj/find-project-root-file "^todo\\.org$"))) (if file (find-file (expand-file-name file root)) (message "No todo.org in project root: %s" root))) (message "Not in a Projectile project"))) (defun cj/project-switch-actions () "On project switch, open TODO.{org,md,txt} or fall back to Magit." (let ((file (cj/find-project-root-file (rx bos "todo." (or "org" "md" "txt") eos)))) (if file (find-file (expand-file-name file (projectile-project-root))) (magit-status (projectile-project-root))))) ;; scan for projects if none are defined (cj/projectile-schedule-project-discovery) ;; don't reuse comp buffers between projects (setq projectile-per-project-compilation-buffer t) (projectile-mode) (setq projectile-switch-project-action #'cj/project-switch-actions)) ;; groups ibuffer by projects (use-package ibuffer-projectile :after projectile :hook (ibuffer-mode . ibuffer-projectile-set-filter-groups)) ;; list all errors project-wide (use-package flycheck-projectile :after projectile :commands flycheck-projectile-list-errors :bind (:map projectile-command-map ("x" . flycheck-projectile-list-errors))) ;; ---------------------------------- Ripgrep ---------------------------------- (use-package deadgrep :after projectile :bind (:map projectile-command-map ("G" . deadgrep) ;; project-wide search ("g" . cj/deadgrep-here) ;; search in context directory ("d" . cj/deadgrep-in-dir)) ;; prompt for directory :config (require 'thingatpt) (defun cj/deadgrep--initial-term () (cond ((use-region-p) (buffer-substring-no-properties (region-beginning) (region-end))) (t (thing-at-point 'symbol t)))) (defun cj/deadgrep-here (&optional term) "Search with Deadgrep in the most relevant directory at point." (interactive) (let* ((root (cond ((derived-mode-p 'dired-mode) (let ((path (dired-get-filename nil t))) (cond ;; If point is on a directory entry, search within that directory. ((and path (file-directory-p path)) path) ;; If point is on a file, search in its containing directory. ((and path (file-regular-p path)) (file-name-directory path)) (t default-directory)))) (buffer-file-name (file-name-directory (file-truename buffer-file-name))) (t default-directory))) (root (file-name-as-directory (expand-file-name root))) (term (or term (read-from-minibuffer "Search: " (cj/deadgrep--initial-term))))) (deadgrep term root))) (defun cj/deadgrep-in-dir (&optional dir term) "Prompt for a directory, then search there with Deadgrep." (interactive) (let* ((dir (or dir (read-directory-name "Search in directory: " default-directory nil t))) (dir (file-name-as-directory (expand-file-name dir))) (term (or term (read-from-minibuffer "Search: " (cj/deadgrep--initial-term))))) (deadgrep term dir)))) (with-eval-after-load 'dired (keymap-set dired-mode-map "G" #'cj/deadgrep-here)) ;; ---------------------------------- Snippets --------------------------------- ;; reusable code and text (defun cj/--yas-activate-fundamental-extras () "Activate `fundamental-mode' as an extra yasnippet mode in this buffer. Hooked onto `yas-minor-mode-hook' so every buffer also consults `snippets/fundamental-mode/' regardless of the buffer's own major mode. This is what makes universal snippets like =