LSP

(use-package lsp-mode
  :ensure t
  :init (setq lsp-keymap-prefix "C-c C-l"
              lsp-prefer-flymake nil)
  :commands (lsp make-lsp-client lsp-register-client)
  :hook (lsp-mode . lsp-enable-which-key-integration)
  :config
  ;; this is a particularly nasty bug
  ;; https://github.com/bbatsov/projectile/issues/1387
  (defun do.lsp/dont-resolve-symlinks-projectile (fun &rest args)
    "Call FUN with ARGS but disable the effects of `file-truename'."
    (cl-letf (((symbol-function 'file-truename) #'identity))
      (apply fun args)))
  (advice-add 'lsp--suggest-project-root :around
              #'do.lsp/dont-resolve-symlinks-projectile)
  <<do.lsp/docker>>)

(use-package lsp-ui
  :ensure t
  :init (setq lsp-ui-flycheck-enable t)
  :commands lsp-ui-mode)

(use-package dap-mode
  :ensure t :after lsp-mode
  :config
  (dap-mode t)
  (dap-ui-mode t))

clangd in Docker

This is the start of some code to allow for clangd in a docker container. But I can't get this to compile yet so I'll just park it here for now.

(require 'dash)
(require 'lsp)
(require 'lsp-clangd)
(require 's)

;;
;; Helper functions. I copied some of them from https://github.com/emacs-lsp/lsp-docker/
;;

;; stolen from lsp-docker
(defun lsp-docker--uri->path (path-mappings docker-container-name uri)
  "Turn docker URI into host path.
Argument PATH-MAPPINGS dotted pair of (host-path . container-path).
Argument DOCKER-CONTAINER-NAME name to use when running container.
Argument URI the uri to translate."
  (let ((path (lsp--uri-to-path-1 uri)))
    (-if-let ((local . remote) (-first (-lambda ((_ . docker-path))
                                         (s-contains? docker-path path))
                                       path-mappings))
        (s-replace remote local path)
      (format "/docker:%s:%s" docker-container-name path))))

(defun lsp-docker--path->uri (path-mappings path)
  "Turn host PATH into docker uri.
Argument PATH-MAPPINGS dotted pair of (host-path . container-path).
Argument PATH the path to translate."
  (lsp--path-to-uri-1
   (-if-let ((local . remote) (-first (-lambda ((local-path . _))
                                        (s-contains? local-path path))
                                      path-mappings))
       (s-replace local remote path)
     (user-error "The path %s is not under path mappings" path))))

;; my functions to use docker containers

(defun do.cpp/format-docker-path-mappings (path-mappings)
  (->> path-mappings
       (-map (-lambda ((path . docker-path))
               (format "-v %s:%s" path docker-path)))
       (s-join " ")))

(defun do.cpp/launch-new-container (docker-container-name path-mappings docker-image-id server-command)
  "Return the docker command to be executed on host.
Argument DOCKER-CONTAINER-NAME name to use for container.
Argument PATH-MAPPINGS dotted pair of (host-path . container-path).
Argument DOCKER-IMAGE-ID the docker container to run language servers with.
Argument SERVER-COMMAND the language server command to run inside the container."
  (split-string
   (--doto (format "docker run --name %s --rm -i %s %s %s"
                   docker-container-name
                   (do.cpp/format-docker-path-mappings path-mappings)
                   docker-image-id
                   server-command))
   " "))


(defun do.cpp/exec-in-running-container (docker-container-name command)
  "Return the docker command to be executed on host.
The goal is to run inside of an already running container.
Argument DOCKER-CONTAINER-NAME name to use for container.
Argument COMMAND the language server command to run inside the container."
  (split-string (--doto (format "docker exec -i %s %s" docker-container-name command)) " "))

(defun do.cpp/is-docker-container-running? (container-name)
  "See if a container with the name CONTAINER-NAME is currently running."
  (let ((output (shell-command-to-string
                 (format "docker container ls -q --filter \"name=%s\"" container-name))))
    (not (string-empty-p output))))

;;
;; these variables (in addition to `do-cpp-project-dir' and `do-cpp-build-dir')
;; need to be set in the .dir-locals.el file to make this work.
;;

(defvar do-cpp-docker-image-name nil
  "The name of the development docker container image.")

(defvar do-cpp-docker-container-name nil
  "The name of the development docker container")

(defvar do-cpp-docker-work-dir nil
  "The path to the working directory of the dev docker container.")

(defvar do-cpp-docker-work-dir-docker-path "/work"
  "The default path to the working directory of the dev docker container.
(This is the path in the container)")

(defvar do-cpp-docker-clangd-path "clangd"
  "The path to the clangd executable inside of the docker container.")

;;
;; define a language server client that connects to a clangd in a
;; project-specific docker container
;;

(let ((client (copy-lsp--client (gethash 'clangd lsp-clients))))
  (setf
   ;; higher priority wins, we want to try this one first
   (lsp--client-priority client) 20
   ;; we use this activation function to determine whether the buffer should
   ;; use the remote clangd or not. `do-cpp-docker-container-name' needs to be
   ;; set as a dir-local variable and needs to contain the name of a docker
   ;; container, same as the others
   (lsp--client-activation-fn client) (lambda (filename major-mode)
                                        (and (bound-and-true-p do-cpp-build-dir)
                                             (bound-and-true-p do-cpp-project-dir)
                                             (bound-and-true-p do-cpp-docker-image-name)
                                             (bound-and-true-p do-cpp-docker-container-name)
                                             (bound-and-true-p do-cpp-docker-work-dir)
                                             (bound-and-true-p do-cpp-docker-clangd-path)))
   ;; we use the path conversion functions to convert between local and docker
   ;; paths. The convention is that I map exactly one directory
   ;; (`do-cpp-docker-work-dir') to `do-cpp-docker-work-dir-docker-path' in the
   ;; container. the LSP magic in the background translates between these
   ;; paths.
   (lsp--client-uri->path-fn client) (lambda (uri)
                                       (let ((path-mappings
                                              (list (cons (file-name-as-directory (file-truename do-cpp-docker-work-dir))
                                                          (file-name-as-directory do-cpp-docker-work-dir-docker-path)))))
                                         (lsp-docker--uri->path path-mappings
                                                                do-cpp-docker-container-name
                                                                uri)))
   (lsp--client-path->uri-fn client) (lambda (path)
                                       (let ((path-mappings
                                              (list (cons (file-name-as-directory (file-truename do-cpp-docker-work-dir))
                                                          (file-name-as-directory do-cpp-docker-work-dir-docker-path)))))
                                         (lsp-docker--path->uri path-mappings (file-truename path))))
   ;; we create an stdio connection through docker
   (lsp--client-new-connection client) (lsp-stdio-connection
                                        (lambda ()
                                          ;; point the clangd to the compile_commands.json file in the build directory
                                          (let ((server-command
                                                 (format "%s --compile-commands-dir %s"
                                                         do-cpp-docker-clangd-path
                                                         (s-replace (file-truename do-cpp-docker-work-dir)
                                                                    (file-name-as-directory do-cpp-docker-work-dir-docker-path)
                                                                    (file-name-as-directory (file-truename do-cpp-build-dir)))))
                                                (path-mappings
                                                 (list (cons (file-name-as-directory (file-truename do-cpp-docker-work-dir))
                                                          (file-name-as-directory do-cpp-docker-work-dir-docker-path)))))
                                            ;; either start a new container or execute in one that is currently running
                                            (if (do.cpp/is-docker-container-running? do-cpp-docker-container-name)
                                                (do.cpp/exec-in-running-container do-cpp-docker-container-name
                                                                                  server-command)
                                              (do.cpp/launch-new-container do-cpp-docker-container-name
                                                                           path-mappings
                                                                           do-cpp-docker-image-name
                                                                           server-command))))))
  ;; just because we might need to run a few of these at the same time, we hack
  ;; it and register five of them and then use dir-locals to decide which one
  ;; to launch. it ain't pretty, but it works.
  (dolist (id '(docker-clangd docker-clangd-1 docker-clangd-2 docker-clangd-3 docker-clangd-4))
    (let ((c (copy-lsp--client client)))
      (setf (lsp--client-server-id c) id)
      (unless (eq id 'docker-clangd)
        (setf (lsp--client-priority c) 10))
      (lsp-register-client c))))

;; hacketty hacckety: since I am using `file-truename' in the path to uri
;; conversions, this screws up the internal hash table for clangd
;; diagnostics. The issue can be reproduced by attaching ielm to a cpp buffer
;; (M-x ielm-change-working-buffer) and seeing the difference in outputs:
;; (lsp--get-buffer-diagnostics) and (json-encode (lsp-diagnostics)). The gist
;; is that the diagnostics are arriving from the checker, but a hashtable
;; lookup inserts them with the key (buffer-file-name) but then attempts to
;; read them with the key (file-truename (buffer-file-name)). This happens
;; because I am using `file-truename' in the uri->path converseion function.
;;
;; There are two options: 1. be consistent between the dirlocals paths and the
;; way I access the files in emacs (i.e., only use the linked paths when
;; accessing) or 2. try to hack LSP. There is an avenue to get this to work by
;; hacking the function `lsp--fix-path-casing'. This function is exclusively
;; called before inserting and looking up `buffer-file-name' in the diagnostics
;; hash table... So I can just advise it and splice in a call to
;; `file-truename'... lord have mercy. I will definitely have to fix this in a
;; later version...

;; (add-function :filter-return (symbol-function 'lsp--fix-path-casing) #'file-truename)

;; the problem with the above approach is that this might screw me up with
;; other checkers---once where I am not doing the strange translating bit. TBH
;; I should just stop using softlinks.

;; let us try this:
(setq find-file-visit-truename t)

;; we also clone the regular clangd a few times so we can open multiple C and
;; C++ projects at the same time.
(dolist (id '(clangd-1 clangd-2 clangd-3 clangd-4))
    (let ((c (copy-lsp--client (gethash 'clangd lsp-clients))))
      (setf (lsp--client-server-id c) id
            (lsp--client-priority c) -5)
      (lsp-register-client c)))