dev-config.el Wed, Jul 16, 2025

;;; dev-config.el --- Development tools and programming modes -*- lexical-binding: t -*-

;;; Commentary:
;; Configuration for programming languages, completion, and development tools

;;; Code:

;; Company for auto-completion (but prevent RET from completing)
(use-package company
  :ensure t
  :bind (:map company-active-map
              ("M-n" . company-select-next)
              ("M-p" . company-select-previous)
              ("<return>" . nil)  ; Prevent RET from completing
              ("RET" . nil)       ; Prevent RET from completing
              ("TAB" . company-complete-selection)
              ("<tab>" . company-complete-selection))
  :custom
  (company-idle-delay 0.2)
  (company-minimum-prefix-length 2)
  (company-show-quick-access 'left)
  (company-tooltip-flip-when-above t)
  (company-require-match nil)  ; Don't force completion
  :hook
  (prog-mode . company-mode))

(use-package eglot
  :ensure nil  ; Built into Emacs 29+
  :hook ((python-mode . eglot-ensure)
         (go-mode . eglot-ensure))
  :custom
  (eglot-autoshutdown t)
  (eldoc-echo-area-use-multiline-p 4)
  (eglot-confirm-server-initiated-edits nil)
  :config
  (setq eglot-stay-out-of nil)
  (setq eglot-report-progress nil)
  (add-to-list 'eglot-server-programs
               '(python-mode . ("pylsp")))
  (add-to-list 'eglot-server-programs
               '(go-mode . ("gopls"))))

;; breadcrumb mode for help tracking where we're at
(use-package breadcrumb
  :ensure t
  :init
  (breadcrumb-mode 1)
  :config
  ;; Optional: Fine-tune refresh delay
  ;; (setq breadcrumb-idle-delay 0.5)

  ;; Customize separators or max lengths if desired
  ;; (setq breadcrumb-imenu-crumb-separator " / ")
  ;; (setq breadcrumb-imenu-max-length 40)
  )

(defun find-fossil-checkout-dir (&optional start-dir)
  "Find the fossil checkout file.
Optional START-DIR which defaults to current."
  (message "ENTERED FUNCTION")
  (let* ((start-path (expand-file-name (or start-dir default-directory)))
         (current-dir start-path)
         found)
    (message "Starting search at: %s" current-dir)
    (while (and (not found)
                current-dir
                (not (string= current-dir "/")))
      (let ((checkout-file (expand-file-name ".fslckout" current-dir)))
        (message "Checking: %s" checkout-file)
        (if (file-exists-p checkout-file)
            (progn
              (setq found current-dir)
              (message "Found checkout at: %s" current-dir))
          (setq current-dir
                (file-name-directory (directory-file-name current-dir))))))
    (or found (message "No checkout found"))))

(defun find-directory-recursive (dir match-fn &optional skip-fn)
  "Recursively search DIR for a directory matching MATCH-FN.
MATCH-FN is called with the full path of each directory.
Optional SKIP-FN is called with each directory path before descending;
  return t to skip that directory.
Returns the matching directory path if found, nil otherwise."
  (when (and (file-directory-p dir)
             (file-readable-p dir)
             (not (and skip-fn (funcall skip-fn dir))))
    (if (funcall match-fn dir)
        dir  ; Return the actual directory path when found
      (let ((files (directory-files dir t directory-files-no-dot-files-regexp))
            found)
        (while (and files (not found))
          (let ((file (car files)))
            (when (file-directory-p file)
              (setq found (find-directory-recursive file match-fn skip-fn)))
            (setq files (cdr files))))
        found))))

;; venv madness - this is kind of a mess, but it works somehow
(defun dws/set-python-path (directory)
  "Set up PYTHONPATH to use the given DIRECTORY.
If eglot is running, reconnects it to pick up the new path."
  (interactive "DPython path directory: ")
  (let ((old-pythonpath (getenv "PYTHONPATH")))
    ;; Set the new PYTHONPATH
    (setenv "PYTHONPATH" (expand-file-name directory))
    ;; Update Emacs Python paths if needed
    (when (boundp 'python-shell-extra-pythonpaths)
      (setq python-shell-extra-pythonpaths
            (list (expand-file-name directory))))
    ;; Reconnect eglot if it's running and path changed
    (when (and (bound-and-true-p eglot--managed-mode)
               (not (string= old-pythonpath (getenv "PYTHONPATH"))))
      (message "PYTHONPATH changed, reconnecting eglot...")
      (eglot-reconnect))))

;; Syntax checking with Flycheck
(use-package flycheck
  :diminish
  :hook (after-init . global-flycheck-mode)
  :custom
  (flycheck-check-syntax-automatically '(save idle-changes mode-enabled))
  (flycheck-idle-change-delay 1.25)
  (flycheck-python-pylint-executable "~/python/bin/pylint")
  (flycheck-flake8-maximum-line-length 120)
  (flycheck-disabled-checkers '(go-gofmt go-build go-test rst-sphinx html-tidy)))

;; Go Mode Configuration
(use-package go-mode
  :mode ("\\.go\\'" . go-mode)
  :hook (before-save . dws/go-save-hook)
  :config
  (defun dws/go-save-hook ()
    "Run formatting before saving."
    (when (bound-and-true-p eglot--managed-mode)
      (eglot-format-buffer)
      (eglot-code-action-organize-imports nil)))
  (setq indent-tabs-mode 1))

(use-package pyvenv
  :ensure t
  :config
  (pyvenv-mode 1))

;; Python development
(use-package python
  :ensure nil  ; Built into Emacs
  :hook ((python-mode . dws-python-mode-hook)
         (python-mode . eglot-ensure))
  :custom
  (python-shell-interpreter (concat home-dir "/python/bin/python"))
  (python-indent-guess-indent-offset nil)
  (python-indent-offset 4)
  (python-indent-trigger-commands '(indent-for-tab-command yas-expand yas/expand))
  :config
  ;; Custom python-mode hook
  (defun dws-python-mode-hook ()
    "Simple Python setup."
    (setq-local tab-width 4)
    (setq-local indent-tabs-mode nil)
    (setq indent-region-function 'python-indent-region)
    (unbind-key "C-c c" python-mode-map)
    (add-hook 'before-save-hook 'delete-trailing-whitespace nil t))

  ;; Handle paste and electric indent
  (setq-local electric-indent-chars '(?\n))  ; Only newlines trigger reindentation
  
  ;; Add a paste advice to maintain our settings
  (advice-add 'yank :after (lambda (&rest _)
                            (setq-local indent-tabs-mode nil)))
  (advice-add 'yank-pop :after (lambda (&rest _)
                                (setq-local indent-tabs-mode nil)))

  ;; Add ignored extensions for completion
  (add-to-list 'completion-ignored-extensions "pyc")

  ;; Clean Python style settings
  (setq delete-trailing-lines t))

;; Web Development
(use-package web-mode
  :mode ("\\.html\\'" . web-mode)
  :hook dws/setdjangoengine
  :config
  (setq web-mode-engines-alist
        '(("django" . "\\.dhtml\\'")
          ("go" . "\\.gohtml\\'")))
  (setq web-mode-ac-sources-alist
        '(("html" . (ac-source-emmet-html-aliases ac-source-emmet-html-snippets))
          ("css" . (ac-source-css-property ac-source-emmet-css-snippets)))))

;; Projectile configuration
(use-package projectile
  :ensure t
  :bind-keymap
  ("C-c p" . projectile-command-map)
  :custom
  (projectile-completion-system 'default)  ; Works with vertico/consult
  (projectile-indexing-method 'native)
  (projectile-enable-caching t)
  (projectile-ignored-projects '("~/" "/tmp"))
  (projectile-globally-ignored-files
   '(".DS_Store"))
  (projectile-ignored-directories
   '(".git" ".hg" "g/src/kfnm.us/vendor/" "build" ".stversions"
     "sites/thesergents/public" "sites/leapfrog/public" "sites/twlk9/public"
     "blog/public" "g/src/pkg" "g/bin"))
  :config
  ;; Project type detection
  (projectile-register-project-type 'fossil '(".fslckout")
                                  :project-file ".fslckout")
  (projectile-register-project-type 'python '("requirements.txt")
									:project-file "requirements.txt")

  (defvar my-projectile-ignored-patterns
	'("\\.DS_Store$" "\\.log$" "\\.pyc$") ; Add more patterns here
	"List of regex patterns to ignore in Projectile.")

  (defun filter-out-ignored-files (files)
	"Filter out files matching `my-projectile-ignored-patterns` from FILES."
	(seq-remove (lambda (file)
                  (seq-some (lambda (pattern)
                              (string-match-p pattern file))
							my-projectile-ignored-patterns))
				files))
  (advice-add 'projectile-project-files :filter-return #'filter-out-ignored-files)
  (projectile-mode +1))

;; New version (projectile)
(defun dws/setdjangoengine ()
  "Set django as web engine when in django dir."
  (when (projectile-project-p)
    (when (file-exists-p (expand-file-name "manage.py" (projectile-project-root)))
      (web-mode-set-engine "django"))))

;; Magit Configuration
(use-package magit-mode
  :config
  (defun git-commit-finish-query-functions (force) t)
  (setq git-commit-summary-max-length 500))

;; YASnippet Configuration
(use-package yasnippet
  :diminish yas-minor-mode
  :custom
  (yas-snippet-dirs '("~/.emacs.d/snippets" yas-installed-snippets-dir))
  :config
  (yas-global-mode 1))

;; Utility Packages
(use-package avy
  :demand
  :bind (("M-m" . avy-goto-word-1)
         ("M-g f" . avy-goto-line)))

(use-package expand-region
  :bind ("M-SPC" . er/expand-region))

;; Programming mode common configuration
(use-package prog-mode
  :config
  (defun toggle-comments ()
    "Comments or uncomments region or current line if no active region."
    (interactive)
    (let (start end)
      (if (region-active-p)
          (setq start (region-beginning) end (region-end))
        (setq start (line-beginning-position) end (line-end-position)))
      (comment-or-uncomment-region start end)
      (forward-line 1)))
  :bind ("C-c c" . toggle-comments))

;; Electric pair mode configuration
(setq electric-pair-inhibit-predicate 'electric-pair-conservative-inhibit)
(electric-pair-mode t)

;; Uniquify buffer names
(use-package uniquify
  :demand
  :custom
  (uniquify-buffer-name-style 'forward))

;; TRAMP configuration
(use-package tramp
  :custom
  (tramp-default-method "ssh"))

;; Ansible YAML configuration
(defun dws/in-ansible-directory-p (file)
  "Check if FILE is in an ansible directory or its subdirectories."
  (and file
       (string-match-p "/ansible/" (file-truename file))))

(defun dws/set-yaml-mode-for-ansible-files ()
  "Set yaml-mode for files without extensions in ansible directories."
  (when (and (not buffer-file-name)
             (dws/in-ansible-directory-p default-directory))
    (yaml-mode)))

;; Add to auto-mode-alist for files without extensions in ansible directories
(add-to-list 'auto-mode-alist '("/ansible/.*/[^.]+\\'" . yaml-mode))  ; matches files without extensions
(add-hook 'find-file-hook #'dws/set-yaml-mode-for-ansible-files)

(provide 'dev-config)
;;; dev-config.el ends here