;;; eglot-tests.el --- Tests for eglot.el            -*- lexical-binding: t; -*-

;; Copyright (C) 2018-2023 Free Software Foundation, Inc.

;; Author: João Távora <joaotavora@gmail.com>
;; Keywords: tests

;; This file is part of GNU Emacs.

;; GNU Emacs is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; GNU Emacs is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Tests for lisp/progmodes/eglot.el
;;
;; Many of these tests rely on the availability of third-party LSP
;; servers.  They are automatically skipped if the program is not
;; available.
;;
;; Some of these tests rely on the GNU ELPA package company.el and
;; yasnippet.el being available.

;; Some of the tests require access to a remote host files, which is
;; mocked in the simplest case.  If you want to test a real Tramp
;; connection, override $REMOTE_TEMPORARY_FILE_DIRECTORY to a suitable
;; value (FIXME: like what?) in order to overwrite the default value.
;;
;; IMPORTANT: Since Eglot is a :core ELPA package, these tests are
;; supposed to run on Emacsen down to 26.3.  Do not use bleeding-edge
;; functionality not compatible with that Emacs version.

;;; Code:
(require 'eglot)
(require 'cl-lib)
(require 'ert)
(require 'tramp)
(require 'ert-x) ; ert-simulate-command
(require 'edebug)
(require 'cc-mode) ; c-mode-hook
(require 'company nil t)
(require 'yasnippet nil t)
(require 'subr-x)
(require 'flymake) ; project-diagnostics

;;; Helpers

(defun eglot--test-message (format &rest args)
  "Message out with FORMAT with ARGS."
  (message "[eglot-tests] %s"
           (apply #'format format args)))

(defmacro eglot--with-fixture (fixture &rest body)
  "Set up FIXTURE, call BODY, tear down FIXTURE.
FIXTURE is a list.  Its elements are of the form (FILE . CONTENT)
to create a readable FILE with CONTENT.  FILE may be a directory
name and CONTENT another (FILE . CONTENT) list to specify a
directory hierarchy."
  (declare (indent 1) (debug t))
  `(eglot--call-with-fixture ,fixture (lambda () ,@body)))

(defun eglot--make-file-or-dir (ass)
  (let ((file-or-dir-name (expand-file-name (car ass)))
        (content (cdr ass)))
    (cond ((listp content)
           (make-directory file-or-dir-name 'parents)
           (let ((default-directory (file-name-as-directory file-or-dir-name)))
             (mapcan #'eglot--make-file-or-dir content)))
          ((stringp content)
           (with-temp-file file-or-dir-name
             (insert content))
           (list file-or-dir-name))
          (t
           (eglot--error "Expected a string or a directory spec")))))

(defun eglot--call-with-fixture (fixture fn)
  "Helper for `eglot--with-fixture'.  Run FN under FIXTURE."
  (let* ((fixture-directory (make-nearby-temp-file "eglot--fixture-" t))
         (default-directory (file-name-as-directory fixture-directory))
         created-files
         new-servers
         test-body-successful-p)
    (eglot--test-message "[%s]: test start" (ert-test-name (ert-running-test)))
    (unwind-protect
        (let ((process-environment
               `(;; Set XDG_CONFIG_HOME to /dev/null to prevent
                 ;; user-configuration influencing language servers
                 ;; (see github#441).
                 ,(format "XDG_CONFIG_HOME=%s" null-device)
                 ;; ... on the flip-side, a similar technique in
                 ;; Emacs's `test/Makefile' spoofs HOME as
                 ;; /nonexistent (and as `temporary-file-directory' in
                 ;; `ert-remote-temporary-file-directory').
                 ;; This breaks some common installations for LSP
                 ;; servers like rust-analyzer, making these tests
                 ;; mostly useless, so we hack around it here with a
                 ;; great big hack.
                 ,(format "HOME=%s"
                          (expand-file-name (format "~%s" (user-login-name))))
                 ,@process-environment))
              (eglot-server-initialized-hook
               (lambda (server) (push server new-servers))))
          (setq created-files (mapcan #'eglot--make-file-or-dir fixture))
          (prog1 (funcall fn)
            (setq test-body-successful-p t)))
      (eglot--test-message "[%s]: %s" (ert-test-name (ert-running-test))
                           (if test-body-successful-p "OK" "FAILED"))
      (unwind-protect
          (let ((eglot-autoreconnect nil))
            (dolist (server new-servers)
              (when (jsonrpc-running-p server)
                (condition-case oops
                    (eglot-shutdown
                     server nil 3 (not test-body-successful-p))
                  (error
                   (eglot--test-message "Non-critical cleanup error: %S" oops))))
              (when (not test-body-successful-p)
                ;; We want to do this after the sockets have
                ;; shut down such that any pending data has been
                ;; consumed and is available in the process
                ;; buffers.
                (let ((buffers (delq nil (list
                                          ;; FIXME: Accessing "internal" symbol here.
                                          (process-buffer (jsonrpc--process server))
                                          (jsonrpc-stderr-buffer server)
                                          (jsonrpc-events-buffer server)))))
                  (cond (noninteractive
                         (dolist (buffer buffers)
                           (eglot--test-message "contents of `%s':" (buffer-name buffer))
                           (princ (with-current-buffer buffer (buffer-string))
                                  'external-debugging-output)))
                        (t
                         (eglot--test-message "Preserved for inspection: %s"
                                              (mapconcat #'buffer-name buffers ", "))))))))
        (eglot--cleanup-after-test fixture-directory created-files)))))

(defun eglot--cleanup-after-test (fixture-directory created-files)
  (let ((buffers-to-delete
         (delq nil (mapcar #'find-buffer-visiting created-files))))
    (eglot--test-message "Killing %s, wiping %s"
                         buffers-to-delete
                         fixture-directory)
    (dolist (buf buffers-to-delete) ;; Have to save otherwise will get prompted.
      (with-current-buffer buf (save-buffer) (kill-buffer)))
    (delete-directory fixture-directory 'recursive)
    ;; Delete Tramp buffers if needed.
    (when (file-remote-p temporary-file-directory)
      (tramp-cleanup-connection
       (tramp-dissect-file-name temporary-file-directory) nil 'keep-password))))

(cl-defmacro eglot--with-timeout (timeout &body body)
  (declare (indent 1) (debug t))
  `(eglot--call-with-timeout ,timeout (lambda () ,@body)))

(defun eglot--call-with-timeout (timeout fn)
  (let* ((tag (gensym "eglot-test-timeout"))
         (timed-out (make-symbol "timeout"))
         (timeout-and-message
          (if (listp timeout) timeout
            (list timeout "waiting for test to finish")))
         (timeout (car timeout-and-message))
         (message (cadr timeout-and-message))
         (timer)
         (retval))
    (unwind-protect
        (setq retval
              (catch tag
                (setq timer
                      (run-with-timer timeout nil
                                      (lambda ()
                                        (unless edebug-active
                                          (throw tag timed-out)))))
                (funcall fn)))
      (cancel-timer timer)
      (when (eq retval timed-out)
        (error "%s" (concat "Timed out " message))))))

(defun eglot--find-file-noselect (file &optional noerror)
  (unless (or noerror
              (file-readable-p file)) (error "%s does not exist" file))
  (find-file-noselect file))

(cl-defmacro eglot--sniffing ((&key server-requests
                                    server-notifications
                                    server-replies
                                    client-requests
                                    client-notifications
                                    client-replies)
                              &rest body)
  "Run BODY saving LSP JSON messages in variables, most recent first."
  (declare (indent 1) (debug (sexp &rest form)))
  (let ((log-event-ad-sym (make-symbol "eglot--event-sniff")))
    `(unwind-protect
         (let ,(delq nil (list server-requests
                               server-notifications
                               server-replies
                               client-requests
                               client-notifications
                               client-replies))
           (advice-add
            #'jsonrpc--log-event :before
            (lambda (_proc message &optional type)
              (cl-destructuring-bind (&key method id _error &allow-other-keys)
                  message
                (let ((req-p (and method id))
                      (notif-p method)
                      (reply-p id))
                  (cond
                   ((eq type 'server)
                    (cond (req-p ,(when server-requests
                                    `(push message ,server-requests)))
                          (notif-p ,(when server-notifications
                                      `(push message ,server-notifications)))
                          (reply-p ,(when server-replies
                                      `(push message ,server-replies)))))
                   ((eq type 'client)
                    (cond (req-p ,(when client-requests
                                    `(push message ,client-requests)))
                          (notif-p ,(when client-notifications
                                      `(push message ,client-notifications)))
                          (reply-p ,(when client-replies
                                      `(push message ,client-replies)))))))))
            '((name . ,log-event-ad-sym)))
           ,@body)
       (advice-remove #'jsonrpc--log-event ',log-event-ad-sym))))

(cl-defmacro eglot--wait-for ((events-sym &optional (timeout 1) message) args &body body)
  (declare (indent 2) (debug (sexp sexp sexp &rest form)))
  `(eglot--with-timeout '(,timeout ,(or message
                                        (format "waiting for:\n%s" (pp-to-string body))))
     (eglot--test-message "waiting for `%s'" (with-output-to-string
                                               (mapc #'princ ',body)))
     (let ((events
            (cl-loop thereis (cl-loop for json in ,events-sym
                                      for method = (plist-get json :method)
                                      when (keywordp method)
                                      do (plist-put json :method
                                                    (substring
                                                     (symbol-name method)
                                                     1))
                                      when (funcall
                                            (jsonrpc-lambda ,args ,@body) json)
                                      return (cons json before)
                                      collect json into before)
                     for i from 0
                     when (zerop (mod i 5))
                     ;; do (eglot--test-message "still struggling to find in %s"
                     ;;                         ,events-sym)
                     do
                     ;; `read-event' is essential to have the file
                     ;; watchers come through.
                     (cond ((fboundp 'flush-standard-output)
                            (read-event nil nil 0.1) (princ ".")
                            (flush-standard-output))
                           (t
                            (read-event "." nil 0.1)))
                     (accept-process-output nil 0.1))))
       (setq ,events-sym (cdr events))
       (cl-destructuring-bind (&key method id &allow-other-keys) (car events)
         (eglot--test-message "detected: %s"
                              (or method (and id (format "id=%s" id))))))))

;; `rust-mode' is not a part of Emacs, so we define these two shims
;; which should be more than enough for testing.
(unless (functionp 'rust-mode)
  (define-derived-mode rust-mode prog-mode "Rust")
  (add-to-list 'auto-mode-alist '("\\.rs\\'" . rust-mode)))

;; `typescript-mode' is not a part of Emacs, so we define these two
;; shims which should be more than enough for testing.
(unless (functionp 'typescript-mode)
  (define-derived-mode typescript-mode prog-mode "TypeScript")
  (add-to-list 'auto-mode-alist '("\\.ts\\'" . typescript-mode)))

(defun eglot--tests-connect (&optional timeout)
  (let* ((timeout (or timeout 10))
         (eglot-sync-connect t)
         (eglot-connect-timeout timeout))
    (apply #'eglot--connect (eglot--guess-contact))))

(defun eglot--simulate-key-event (char)
  "Like (execute-kbd-macro (vector char)), but with `call-interactively'."
  ;; Also, this is a bit similar to what electric-tests.el does.
  (setq last-input-event char)
  (setq last-command-event char)
  (call-interactively (key-binding (vector char))))

(defun eglot--clangd-version ()
  "Report on the clangd version used in various tests."
  (let ((version (shell-command-to-string "clangd --version")))
    (when (string-match "version[[:space:]]+\\([0-9.]*\\)"
                        version)
      (match-string 1 version))))


;;; Unit tests

(ert-deftest eglot-test-eclipse-connect ()
  "Connect to eclipse.jdt.ls server."
  (skip-unless (executable-find "jdtls"))
  (eglot--with-fixture
      '(("project/src/main/java/foo" . (("Main.java" . ""))))
    (with-current-buffer
        (eglot--find-file-noselect "project/src/main/java/foo/Main.java")
      (eglot--sniffing (:server-notifications s-notifs)
        (should (eglot--tests-connect 20))
        (eglot--wait-for (s-notifs 10)
            (&key _id method &allow-other-keys)
          (string= method "language/status"))))))

(defun eglot-tests--auto-detect-running-server-1 ()
  (let (server)
    (eglot--with-fixture
     `(("project" . (("coiso.c" . "bla")
                     ("merdix.c" . "bla")))
       ("anotherproject" . (("cena.c" . "bla"))))
     (with-current-buffer
         (eglot--find-file-noselect "project/coiso.c")
       (should (setq server (eglot--tests-connect)))
       (should (eglot-current-server)))
     (with-current-buffer
         (eglot--find-file-noselect "project/merdix.c")
       (should (eglot-current-server))
       (should (eq (eglot-current-server) server)))
     (with-current-buffer
         (eglot--find-file-noselect "anotherproject/cena.c")
       (should-error (eglot--current-server-or-lose))))))

(ert-deftest eglot-test-auto-detect-running-server ()
  "Visit a file and \\[eglot], then visit a neighbor."
  (skip-unless (executable-find "clangd"))
  (eglot-tests--auto-detect-running-server-1))

(ert-deftest eglot-test-auto-shutdown ()
  "Visit a file and \\[eglot], then kill buffer."
  (skip-unless (executable-find "clangd"))
  (let (server
        buffer)
    (eglot--with-fixture
        `(("project" . (("thingy.c" . "int main() {return 0;}"))))
      (with-current-buffer
          (setq buffer (eglot--find-file-noselect "project/thingy.c"))
        (should (setq server (eglot--tests-connect)))
        (should (eglot-current-server))
        (let ((eglot-autoshutdown nil)) (kill-buffer buffer))
        (should (jsonrpc-running-p server))
        ;; re-find file...
        (setq buffer (eglot--find-file-noselect (buffer-file-name buffer)))
        ;; ;; but now kill it with `eglot-autoshutdown' set to t
        (let ((eglot-autoshutdown t)) (kill-buffer buffer))
        (should (not (jsonrpc-running-p server)))))))

(ert-deftest eglot-test-auto-reconnect ()
  "Start a server.  Kill it.  Watch it reconnect."
  (skip-unless (executable-find "clangd"))
  (let (server (eglot-autoreconnect 1))
    (eglot--with-fixture
        `(("project" . (("thingy.c" . "bla")
                        ("thingy2.c" . "bla"))))
      (with-current-buffer
          (eglot--find-file-noselect "project/thingy.c")
        (should (setq server (eglot--tests-connect)))
        ;; In 1.2 seconds > `eglot-autoreconnect' kill servers. We
        ;; should have a automatic reconnection.
        (run-with-timer 1.2 nil (lambda () (delete-process
                                            (jsonrpc--process server))))
        (while (jsonrpc-running-p server) (accept-process-output nil 0.5))
        (should (eglot-current-server))
        ;; Now try again too quickly
        (setq server (eglot-current-server))
        (let ((proc (jsonrpc--process server)))
          (run-with-timer 0.5 nil (lambda () (delete-process proc)))
          (while (process-live-p proc) (accept-process-output nil 0.5)))
        (should (not (eglot-current-server)))))))

(ert-deftest eglot-test-rust-analyzer-watches-files ()
  "Start rust-analyzer.  Notify it when a critical file changes."
  (skip-unless (executable-find "rust-analyzer"))
  (skip-unless (executable-find "cargo"))
  (let ((eglot-autoreconnect 1))
    (eglot--with-fixture
        '(("watch-project" . (("coiso.rs" . "bla")
                              ("merdix.rs" . "bla"))))
      (with-current-buffer
          (eglot--find-file-noselect "watch-project/coiso.rs")
        (should (zerop (shell-command "cargo init")))
        (eglot--sniffing (
                          :server-requests s-requests
                          :client-notifications c-notifs
                          :client-replies c-replies
                          )
          (should (eglot--tests-connect))
          (let (register-id)
            (eglot--wait-for (s-requests 3)
                (&key id method &allow-other-keys)
              (setq register-id id)
              (string= method "client/registerCapability"))
            (eglot--wait-for (c-replies 1)
                (&key id error &allow-other-keys)
              (and (eq id register-id) (null error))))
          (delete-file "Cargo.toml")
          (eglot--wait-for
              (c-notifs 3 "waiting for didChangeWatchedFiles notification")
              (&key method params &allow-other-keys)
            (and (string= method "workspace/didChangeWatchedFiles")
                 (cl-destructuring-bind (&key uri type)
                     (elt (plist-get params :changes) 0)
                   (and (string= (eglot--path-to-uri "Cargo.toml") uri)
                        (= type 3))))))))))

(ert-deftest eglot-test-basic-diagnostics ()
  "Test basic diagnostics."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("diag-project" .
         (("main.c" . "int main(){froat a = 42.2; return 0;}"))))
    (with-current-buffer
        (eglot--find-file-noselect "diag-project/main.c")
      (eglot--sniffing (:server-notifications s-notifs)
        (eglot--tests-connect)
        (eglot--wait-for (s-notifs 10)
            (&key _id method &allow-other-keys)
          (string= method "textDocument/publishDiagnostics"))
        (flymake-start)
        (goto-char (point-min))
        (flymake-goto-next-error 1 '() t)
        (should (eq 'flymake-error (face-at-point)))))))

(ert-deftest eglot-test-diagnostic-tags-unnecessary-code ()
  "Test rendering of diagnostics tagged \"unnecessary\"."
  (skip-unless (executable-find "clangd"))
  (skip-unless (version<= "14" (eglot--clangd-version)))
  (eglot--with-fixture
      `(("diag-project" .
         (("main.cpp" . "int main(){float a = 42.2; return 0;}"))))
    (with-current-buffer
        (eglot--find-file-noselect "diag-project/main.cpp")
      (eglot--make-file-or-dir '(".git"))
      (eglot--make-file-or-dir
       `("compile_commands.json" .
         ,(jsonrpc--json-encode
           `[(:directory ,default-directory :command "/usr/bin/c++ -Wall -c main.cpp"
                         :file ,(expand-file-name "main.cpp"))])))
      (let ((eglot-server-programs '((c++-mode . ("clangd")))))
        (eglot--sniffing (:server-notifications s-notifs)
          (eglot--tests-connect)
          (eglot--wait-for (s-notifs 10)
              (&key _id method &allow-other-keys)
            (string= method "textDocument/publishDiagnostics"))
          (flymake-start)
          (goto-char (point-min))
          (flymake-goto-next-error 1 '() t)
          (should (eq 'eglot-diagnostic-tag-unnecessary-face (face-at-point))))))))

(defun eglot--eldoc-on-demand ()
  ;; Trick ElDoc 1.1.0 into accepting on-demand calls.
  (eldoc t))

(defun eglot--tests-force-full-eldoc ()
  ;; FIXME: This uses some ElDoc implementation details.
  (when (buffer-live-p eldoc--doc-buffer)
    (with-current-buffer eldoc--doc-buffer
      (let ((inhibit-read-only t))
        (erase-buffer))))
  (eglot--eldoc-on-demand)
  (cl-loop
   repeat 10
   for retval = (and (buffer-live-p eldoc--doc-buffer)
                     (with-current-buffer eldoc--doc-buffer
                       (let ((bs (buffer-string)))
                         (unless (zerop (length bs)) bs))))
   when retval return retval
   do (sit-for 0.5)
   finally (error "eglot--tests-force-full-eldoc didn't deliver")))

(ert-deftest eglot-test-rust-analyzer-hover-after-edit ()
  "Hover and highlightChanges."
  (skip-unless (executable-find "rust-analyzer"))
  (skip-unless (executable-find "cargo"))
  (eglot--with-fixture
      '(("hover-project" .
         (("main.rs" .
           "fn test() -> i32 { let test=3; return te; }"))))
    (with-current-buffer
        (eglot--find-file-noselect "hover-project/main.rs")
      (should (zerop (shell-command "cargo init")))
      (eglot--sniffing (
                        :server-replies s-replies
                        :client-requests c-reqs
                        )
        (eglot--tests-connect)
        (goto-char (point-min))
        (search-forward "return te")
        (insert "st")
        (progn
          ;; simulate these two which don't happen when buffer isn't
          ;; visible in a window.
          (eglot--signal-textDocument/didChange)
          (eglot--eldoc-on-demand))
        (let (pending-id)
          (eglot--wait-for (c-reqs 2)
              (&key id method &allow-other-keys)
            (setq pending-id id)
            (string= method "textDocument/documentHighlight"))
          (eglot--wait-for (s-replies 2)
              (&key id &allow-other-keys)
            (eq id pending-id)))))))

(ert-deftest eglot-test-rename-a-symbol ()
  "Test basic symbol renaming."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("rename-project"
         . (("main.c" .
             "int foo() {return 42;} int main() {return foo();}"))))
    (with-current-buffer
        (eglot--find-file-noselect "rename-project/main.c")
      (eglot--tests-connect)
      (goto-char (point-min)) (search-forward "foo")
      (eglot-rename "bar")
      (should (equal (buffer-string)
                     "int bar() {return 42;} int main() {return bar();}")))))

(defun eglot--wait-for-clangd ()
  (eglot--sniffing (:server-notifications s-notifs)
    (should (eglot--tests-connect))
    (eglot--wait-for (s-notifs 20) (&key method &allow-other-keys)
      (string= method "textDocument/publishDiagnostics"))))

(ert-deftest eglot-test-basic-completions ()
  "Test basic autocompletion in a clangd LSP."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("coiso.c" . "#include <stdio.h>\nint main () {fprin"))))
    (with-current-buffer
        (eglot--find-file-noselect "project/coiso.c")
      (eglot--sniffing (:server-notifications s-notifs)
        (eglot--wait-for-clangd)
        (eglot--wait-for (s-notifs 20) (&key method &allow-other-keys)
          (string= method "textDocument/publishDiagnostics")))
      (goto-char (point-max))
      (completion-at-point)
      (message (buffer-string))
      (should (looking-back "fprintf.?")))))

(ert-deftest eglot-test-non-unique-completions ()
  "Test completion resulting in 'Complete, but not unique'."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("coiso.c" .
                       ,(concat "int foo; int fooey;"
                                "int main() {foo")))))
    (with-current-buffer
        (eglot--find-file-noselect "project/coiso.c")
      (eglot--wait-for-clangd)
      (goto-char (point-max))
      (completion-at-point)
      ;; FIXME: `current-message' doesn't work here :-(
    (with-current-buffer (messages-buffer)
      (save-excursion
        (goto-char (point-max))
        (forward-line -1)
        (should (looking-at "Complete, but not unique")))))))

(ert-deftest eglot-test-basic-xref ()
  "Test basic xref functionality in a clangd LSP."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("coiso.c" .
                       ,(concat "int foo=42; int fooey;"
                                "int main() {foo=82;}")))))
    (with-current-buffer
        (eglot--find-file-noselect "project/coiso.c")
      (should (eglot--tests-connect))
      (search-forward "{foo")
      (call-interactively 'xref-find-definitions)
      (should (looking-at "foo=42")))))

(defvar eglot--test-c-buffer
  "\
void foobarquux(int a, int b, int c){};
void foobazquuz(int a, int b, int f){};
int main() {
")

(declare-function yas-minor-mode nil)

(ert-deftest eglot-test-snippet-completions ()
  "Test simple snippet completion in a clangd LSP."
  (skip-unless (and (executable-find "clangd")
                    (functionp 'yas-minor-mode)))
  (eglot--with-fixture
      `(("project" . (("coiso.c" . ,eglot--test-c-buffer))))
    (with-current-buffer
        (eglot--find-file-noselect "project/coiso.c")
      (yas-minor-mode 1)
      (eglot--wait-for-clangd)
      (goto-char (point-max))
      (insert "foobar")
      (completion-at-point)
      (should (looking-back "foobarquux("))
      (should (looking-at "int a, int b, int c)")))))

(defvar company-candidates)
(declare-function company-mode nil)
(declare-function company-complete nil)

(ert-deftest eglot-test-snippet-completions-with-company ()
  "Test simple snippet completion in a clangd LSP."
  (skip-unless (and (executable-find "clangd")
                    (functionp 'yas-minor-mode)
                    (functionp 'company-complete)))
  (eglot--with-fixture
      `(("project" . (("coiso.c" . ,eglot--test-c-buffer))))
    (with-current-buffer
        (eglot--find-file-noselect "project/coiso.c")
      (yas-minor-mode 1)
      (eglot--wait-for-clangd)
      (goto-char (point-max))
      (insert "foo")
      (company-mode)
      (company-complete)
      (should (looking-back "fooba"))
      (should (= 2 (length company-candidates)))
      ;; this last one is brittle, since there it is possible that
      ;; clangd will change the representation of this candidate
      (should (member "foobazquuz(int a, int b, int f)" company-candidates)))))

(ert-deftest eglot-test-eldoc-after-completions ()
  "Test documentation echo in a clangd LSP."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("coiso.c" . "#include <stdio.h>\nint main () {fprin"))))
    (with-current-buffer
        (eglot--find-file-noselect "project/coiso.c")
      (eglot--wait-for-clangd)
      (goto-char (point-max))
      (completion-at-point)
      (message (buffer-string))
      (should (looking-back "fprintf(?"))
      (unless (= (char-before) ?\() (insert "()") (backward-char))
      (eglot--signal-textDocument/didChange)
      (should (string-match "^fprintf" (eglot--tests-force-full-eldoc))))))

(ert-deftest eglot-test-multiline-eldoc ()
  "Test ElDoc documentation from multiple osurces."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("coiso.c" .
                       "#include <stdio.h>\nint main () {fprintf(blergh);}"))))
    (with-current-buffer
        (eglot--find-file-noselect "project/coiso.c")
      (search-forward "fprintf(ble")
      (eglot--wait-for-clangd)
      (flymake-start nil t) ;; thing brings in the "unknown identifier blergh"
      (let* ((captured-message (eglot--tests-force-full-eldoc)))
        ;; check for signature and error message in the result
        (should (string-match "fprintf" captured-message))
        (should (string-match "blergh" captured-message))
        (should (cl-find ?\n captured-message))))))

(ert-deftest eglot-test-formatting ()
  "Test formatting in the clangd server."
  ;; Beware, default autopep rules can change over time, which may
  ;; affect this test.
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("coiso.c" . ,(concat "#include <stdio.h>\n"
                                            "int main(){fprintf(blergh);}"
                                            "int ble{\n\nreturn 0;}")))))
    (with-current-buffer
        (eglot--find-file-noselect "project/coiso.c")
      (eglot--wait-for-clangd)
      (forward-line)
      ;; Try to format just the second line
      (eglot-format (line-beginning-position) (line-end-position))
      (should (looking-at "int main() { fprintf(blergh); }"))
      ;; ;; now format the whole buffer
      (eglot-format-buffer)
      (should
       (string= (buffer-string)
                "#include <stdio.h>\nint main() { fprintf(blergh); }\nint ble { return 0; }")))))

(ert-deftest eglot-test-rust-on-type-formatting ()
  "Test textDocument/onTypeFormatting against rust-analyzer."
  (skip-unless (executable-find "rust-analyzer"))
  (skip-unless (executable-find "cargo"))
  (eglot--with-fixture
      '(("on-type-formatting-project" .
         (("main.rs" .
           "fn main() -> () {\n  foo\n    .bar()\n  "))))
    (with-current-buffer
        (eglot--find-file-noselect "on-type-formatting-project/main.rs")
      (let ((eglot-server-programs '((rust-mode . ("rust-analyzer")))))
        (should (zerop (shell-command "cargo init")))
        (eglot--sniffing (:server-notifications s-notifs)
          (should (eglot--tests-connect))
          (eglot--wait-for (s-notifs 20) (&key method &allow-other-keys)
            (string= method "textDocument/publishDiagnostics")))
        (goto-char (point-max))
        (eglot--simulate-key-event ?.)
        (should (looking-back "^    \\."))))))

(ert-deftest eglot-test-javascript-basic ()
  "Test basic autocompletion in a JavaScript LSP."
  (skip-unless (and (executable-find "typescript-language-server")
                    (executable-find "tsserver")))
  (eglot--with-fixture
      '(("project" . (("hello.js" . "console.log('Hello world!');"))))
    (with-current-buffer
        (eglot--find-file-noselect "project/hello.js")
      (let ((eglot-server-programs
             '((js-mode . ("typescript-language-server" "--stdio")))))
        (goto-char (point-max))
        (eglot--sniffing (:server-notifications
                          s-notifs
                          :client-notifications
                          c-notifs)
          (should (eglot--tests-connect))
          (eglot--wait-for (s-notifs 2) (&key method &allow-other-keys)
            (string= method "textDocument/publishDiagnostics"))
          (should (not (eq 'flymake-error (face-at-point))))
          (insert "{")
          (eglot--signal-textDocument/didChange)
          (eglot--wait-for (c-notifs 1) (&key method &allow-other-keys)
            (string= method "textDocument/didChange"))
          (eglot--wait-for (s-notifs 2) (&key params method &allow-other-keys)
            (and (string= method "textDocument/publishDiagnostics")
                 (cl-destructuring-bind (&key _uri diagnostics) params
                   (cl-find-if (jsonrpc-lambda (&key severity &allow-other-keys)
                                 (= severity 1))
                               diagnostics)))))))))

(ert-deftest eglot-test-project-wide-diagnostics-typescript ()
  "Test diagnostics through multiple files in a TypeScript LSP."
  (skip-unless (and (executable-find "typescript-language-server")
                    (executable-find "tsserver")))
  (eglot--with-fixture
      '(("project" . (("hello.ts" . "const thing = 5;\nexport { thin }")
                      ("hello2.ts" . "import { thing } from './hello'"))))
    (eglot--make-file-or-dir '(".git"))
    (let ((eglot-server-programs
           '((typescript-mode . ("typescript-language-server" "--stdio")))))
      ;; Check both files because typescript-language-server doesn't
      ;; report all errors on startup, at least not with such a simple
      ;; setup.
      (with-current-buffer (eglot--find-file-noselect "project/hello2.ts")
        (eglot--sniffing (:server-notifications s-notifs)
          (eglot--tests-connect)
          (flymake-start)
          (eglot--wait-for (s-notifs 10)
              (&key _id method &allow-other-keys)
            (string= method "textDocument/publishDiagnostics"))
          (should (= 2 (length (flymake--project-diagnostics)))))
        (with-current-buffer (eglot--find-file-noselect "hello.ts")
          (eglot--sniffing (:server-notifications s-notifs)
            (flymake-start)
            (eglot--wait-for (s-notifs 10)
                (&key _id method &allow-other-keys)
              (string= method "textDocument/publishDiagnostics"))
            (should (= 4 (length (flymake--project-diagnostics))))))))))

(ert-deftest eglot-test-project-wide-diagnostics-rust-analyzer ()
  "Test diagnostics through multiple files in rust-analyzer."
  (skip-unless (executable-find "rust-analyzer"))
  (skip-unless (executable-find "cargo"))
  (skip-unless (executable-find "git"))
  (eglot--with-fixture
      '(("project" .
         (("main.rs" .
           "fn main() -> i32 { return 42.2;}")
          ("other-file.rs" .
           "fn foo() -> () { let hi=3; }"))))
    (let ((eglot-server-programs '((rust-mode . ("rust-analyzer")))))
      ;; Open other-file.rs, and see diagnostics arrive for main.rs,
      ;; which we didn't open.
      (with-current-buffer (eglot--find-file-noselect "project/other-file.rs")
        (should (zerop (shell-command "git init")))
        (should (zerop (shell-command "cargo init")))
        (eglot--sniffing (:server-notifications s-notifs)
          (eglot--tests-connect)
          (flymake-start)
          (eglot--wait-for (s-notifs 20)
              (&key _id method params &allow-other-keys)
            (and (string= method "textDocument/publishDiagnostics")
                 (string-suffix-p "main.rs" (plist-get params :uri))))
          (let* ((diags (flymake--project-diagnostics)))
            (should (cl-some (lambda (diag)
                               (let ((locus (flymake-diagnostic-buffer diag)))
                                 (and (stringp (flymake-diagnostic-buffer diag))
                                      (string-suffix-p "main.rs" locus))))
                             diags))))))))

(ert-deftest eglot-test-json-basic ()
  "Test basic autocompletion in vscode-json-languageserver."
  (skip-unless (executable-find "vscode-json-languageserver"))
  (skip-unless (fboundp 'yas-minor-mode))
  (eglot--with-fixture
      '(("project" .
         (("p.json" . "{\"foo.b")
          ("s.json" . "{\"properties\":{\"foo.bar\":{\"default\":\"fb\"}}}")
          (".git" . nil))))
    (with-current-buffer
        (eglot--find-file-noselect "project/p.json")
      (yas-minor-mode)
      (goto-char 2)
      (insert "\"$schema\": \"file://"
              (file-name-directory buffer-file-name) "s.json\",")
      (let ((eglot-server-programs
             '((js-mode . ("vscode-json-languageserver" "--stdio")))))
        (goto-char (point-max))
        (should (eglot--tests-connect))
        (completion-at-point)
        (should (looking-back "\"foo.bar\": \""))
        (should (looking-at "fb\"$"))))))

(defun eglot-tests--lsp-abiding-column-1 ()
  (eglot--with-fixture
      '(("project" .
         (("foo.c" . "const char write_data[] = u8\"🚂🚃🚄🚅🚆🚈🚇🚈🚉🚊🚋🚌🚎🚝🚞🚟🚠🚡🛤🛲\";"))))
    (let ((eglot-server-programs
           '((c-mode . ("clangd")))))
      (with-current-buffer
          (eglot--find-file-noselect "project/foo.c")
        (setq-local eglot-move-to-linepos-function #'eglot-move-to-utf-16-linepos)
        (setq-local eglot-current-linepos-function #'eglot-utf-16-linepos)
        (eglot--sniffing (:client-notifications c-notifs)
          (eglot--tests-connect)
          (end-of-line)
          (insert "p ")
          (eglot--signal-textDocument/didChange)
          (eglot--wait-for (c-notifs 2) (&key params &allow-other-keys)
            (should (equal 71 (cadddr (cadadr (aref (cadddr params) 0))))))
          (beginning-of-line)
          (should (eq eglot-move-to-linepos-function #'eglot-move-to-utf-16-linepos))
          (funcall eglot-move-to-linepos-function 71)
          (should (looking-at "p")))))))

(ert-deftest eglot-test-lsp-abiding-column ()
  "Test basic LSP character counting logic."
  (skip-unless (executable-find "clangd"))
  (eglot-tests--lsp-abiding-column-1))

(ert-deftest eglot-test-ensure ()
  "Test basic `eglot-ensure' functionality."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("foo.c" . "int foo() {return 42;}")
                      ("bar.c" . "int bar() {return 42;}"))))
    (let ((c-mode-hook '(eglot-ensure))
          server)
      ;; need `ert-simulate-command' because `eglot-ensure'
      ;; relies on `post-command-hook'.
      (with-current-buffer
          (ert-simulate-command
           '(find-file "project/foo.c"))
        ;; FIXME: This test fails without this sleep on my machine.
        ;; Figure out why and solve this more cleanly.
        (sleep-for 0.1)
        (should (setq server (eglot-current-server))))
      (with-current-buffer
          (ert-simulate-command
           '(find-file "project/bar.c"))
        (should (eq server (eglot-current-server)))))))

(ert-deftest eglot-test-slow-sync-connection-wait ()
  "Connect with `eglot-sync-connect' set to t."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("something.c" . "int foo() {return 42;}"))))
    (with-current-buffer
        (eglot--find-file-noselect "project/something.c")
      (let ((eglot-sync-connect t)
            (eglot-server-programs
             `((c-mode . ("sh" "-c" "sleep 1 && clangd")))))
        (should (eglot--tests-connect 3))))))

(ert-deftest eglot-test-slow-sync-connection-intime ()
  "Connect synchronously with `eglot-sync-connect' set to 2."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("something.c" . "int foo() {return 42;}"))))
    (with-current-buffer
        (eglot--find-file-noselect "project/something.c")
      (let ((eglot-sync-connect 2)
            (eglot-server-programs
             `((c-mode . ("sh" "-c" "sleep 1 && clangd")))))
        (should (eglot--tests-connect 3))))))

(ert-deftest eglot-test-slow-async-connection ()
  "Connect asynchronously with `eglot-sync-connect' set to 2."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("something.c" . "int foo() {return 42;}"))))
    (with-current-buffer
        (eglot--find-file-noselect "project/something.c")
      (let ((eglot-sync-connect 1)
            (eglot-server-programs
             `((c-mode . ("sh" "-c" "sleep 2 && clangd")))))
        (should-not (apply #'eglot--connect (eglot--guess-contact)))
        (eglot--with-timeout 3
          (while (not (eglot-current-server))
            (accept-process-output nil 0.2))
          (should (eglot-current-server)))))))

(ert-deftest eglot-test-slow-sync-timeout ()
  "Failed attempt at connection synchronously."
  (skip-unless (executable-find "clangd"))
  (eglot--with-fixture
      `(("project" . (("something.c" . "int foo() {return 42;}"))))
    (with-current-buffer
        (eglot--find-file-noselect "project/something.c")
      (let ((eglot-sync-connect t)
            (eglot-connect-timeout 1)
            (eglot-server-programs
             `((c-mode . ("sh" "-c" "sleep 2 && clangd")))))
        (should-error (apply #'eglot--connect (eglot--guess-contact)))))))

(ert-deftest eglot-test-capabilities ()
  "Unit test for `eglot--server-capable'."
  (cl-letf (((symbol-function 'eglot--capabilities)
             (lambda (_dummy)
               ;; test data lifted from Golangserver example at
               ;; https://github.com/joaotavora/eglot/pull/74
               (list :textDocumentSync 2 :hoverProvider t
                     :completionProvider '(:triggerCharacters ["."])
                     :signatureHelpProvider '(:triggerCharacters ["(" ","])
                     :definitionProvider t :typeDefinitionProvider t
                     :referencesProvider t :documentSymbolProvider t
                     :workspaceSymbolProvider t :implementationProvider t
                     :documentFormattingProvider t :xworkspaceReferencesProvider t
                     :xdefinitionProvider t :xworkspaceSymbolByProperties t)))
            ((symbol-function 'eglot--current-server-or-lose)
             (lambda () nil)))
    (should (eql 2 (eglot--server-capable :textDocumentSync)))
    (should (eglot--server-capable :completionProvider :triggerCharacters))
    (should (equal '(:triggerCharacters ["."]) (eglot--server-capable :completionProvider)))
    (should-not (eglot--server-capable :foobarbaz))
    (should-not (eglot--server-capable :textDocumentSync :foobarbaz))))

(defmacro eglot--without-interface-warnings (&rest body)
  (let ((eglot-strict-mode nil))
    (macroexpand-all (macroexp-progn body) macroexpand-all-environment)))

(ert-deftest eglot-test-strict-interfaces ()
  (let ((eglot--lsp-interface-alist
         `((FooObject . ((:foo :bar) (:baz))))))
    (eglot--without-interface-warnings
     (should
      (equal '("foo" . "bar")
             (let ((eglot-strict-mode nil))
               (eglot--dbind (foo bar) `(:foo "foo" :bar "bar")
                 (cons foo bar)))))
     (should-error
      (let ((eglot-strict-mode '(disallow-non-standard-keys)))
        (eglot--dbind (foo bar) `(:foo "foo" :bar "bar" :fotrix bargh)
          (cons foo bar))))
     (should
      (equal '("foo" . "bar")
             (let ((eglot-strict-mode nil))
               (eglot--dbind (foo bar) `(:foo "foo" :bar "bar" :fotrix bargh)
                 (cons foo bar)))))
     (should-error
      (let ((eglot-strict-mode '(disallow-non-standard-keys)))
        (eglot--dbind ((FooObject) foo bar) `(:foo "foo" :bar "bar" :fotrix bargh)
          (cons foo bar))))
     (should
      (equal '("foo" . "bar")
             (let ((eglot-strict-mode '(disallow-non-standard-keys)))
               (eglot--dbind ((FooObject) foo bar) `(:foo "foo" :bar "bar" :baz bargh)
                 (cons foo bar)))))
     (should
      (equal '("foo" . nil)
             (let ((eglot-strict-mode nil))
               (eglot--dbind ((FooObject) foo bar) `(:foo "foo" :baz bargh)
                 (cons foo bar)))))
     (should
      (equal '("foo" . "bar")
             (let ((eglot-strict-mode '(enforce-required-keys)))
               (eglot--dbind ((FooObject) foo bar) `(:foo "foo" :bar "bar" :baz bargh)
                 (cons foo bar)))))
     (should-error
      (let ((eglot-strict-mode '(enforce-required-keys)))
        (eglot--dbind ((FooObject) foo bar) `(:foo "foo" :baz bargh)
          (cons foo bar)))))))

(ert-deftest eglot-test-dcase ()
  (eglot--without-interface-warnings
   (let ((eglot--lsp-interface-alist
          `((FooObject . ((:foo :bar) (:baz)))
            (CodeAction (:title) (:kind :diagnostics :edit :command))
            (Command ((:title . string) (:command . string)) (:arguments)))))
     (should
      (equal
       "foo"
       (eglot--dcase `(:foo "foo" :bar "bar")
         (((FooObject) foo)
          foo))))
     (should
      (equal
       (list "foo" '(:title "hey" :command "ho") "some edit")
       (eglot--dcase '(:title "foo"
                              :command (:title "hey" :command "ho")
                              :edit "some edit")
         (((Command) _title _command _arguments)
          (ert-fail "Shouldn't have destructured this object as a Command"))
         (((CodeAction) title edit command)
          (list title command edit)))))
     (should
      (equal
       (list "foo" "some command" nil)
       (eglot--dcase '(:title "foo" :command "some command")
         (((Command) title command arguments)
          (list title command arguments))
         (((CodeAction) _title _edit _command)
          (ert-fail "Shouldn't have destructured this object as a CodeAction"))))))))

(ert-deftest eglot-test-dcase-issue-452 ()
  (let ((eglot--lsp-interface-alist
         `((FooObject . ((:foo :bar) (:baz)))
           (CodeAction (:title) (:kind :diagnostics :edit :command))
           (Command ((string . :title) (:command . string)) (:arguments)))))
    (should
     (equal
      (list "foo" '(:command "cmd" :title "alsofoo"))
      (eglot--dcase '(:title "foo" :command (:command "cmd" :title "alsofoo"))
        (((Command) _title _command _arguments)
         (ert-fail "Shouldn't have destructured this object as a Command"))
        (((CodeAction) title command)
         (list title command)))))))

(cl-defmacro eglot--guessing-contact ((interactive-sym
                                       prompt-args-sym
                                       guessed-class-sym guessed-contact-sym
                                       &optional guessed-major-modes-sym
                                       guessed-lang-ids-sym)
                                      &body body)
  "Guess LSP contact with `eglot--guessing-contact', evaluate BODY.

BODY is evaluated twice, with INTERACTIVE bound to the boolean passed to
`eglot--guess-contact' each time.

If the user would have been prompted, PROMPT-ARGS-SYM is bound to
the list of arguments that would have been passed to
`read-shell-command', else nil.  GUESSED-CLASS-SYM,
GUESSED-CONTACT-SYM, GUESSED-LANG-IDS-SYM and
GUESSED-MAJOR-MODES-SYM are bound to the useful return values of
`eglot--guess-contact'.  Unless the server program evaluates to
\"a-missing-executable.exe\", this macro will assume it exists."
  (declare (indent 1) (debug t))
  (let ((i-sym (cl-gensym)))
    `(dolist (,i-sym '(nil t))
       (let ((,interactive-sym ,i-sym)
             (buffer-file-name "_")
             ,@(when prompt-args-sym `((,prompt-args-sym nil))))
         (cl-letf (((symbol-function 'executable-find)
                    (lambda (name &optional _remote)
                      (unless (string-equal name "a-missing-executable.exe")
                        (format "/totally-mock-bin/%s" name))))
                   ((symbol-function 'read-shell-command)
                    ,(if prompt-args-sym
                         `(lambda (&rest args) (setq ,prompt-args-sym args) "")
                       `(lambda (&rest _dummy) ""))))
           (cl-destructuring-bind
               (,(or guessed-major-modes-sym '_)
                _ ,guessed-class-sym ,guessed-contact-sym
                  ,(or guessed-lang-ids-sym '_))
               (eglot--guess-contact ,i-sym)
             ,@body))))))

(ert-deftest eglot-test-server-programs-simple-executable ()
  (let ((eglot-server-programs '((foo-mode "some-executable")))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'eglot-lsp-server))
      (should (equal guessed-contact '("some-executable"))))))

(ert-deftest eglot-test-server-programs-simple-missing-executable ()
  (let ((eglot-server-programs '((foo-mode "a-missing-executable.exe")))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (interactive-p prompt-args guessed-class guessed-contact)
      (should (equal (not prompt-args) (not interactive-p)))
      (should (equal guessed-class 'eglot-lsp-server))
      (should (or prompt-args
                  (equal guessed-contact '("a-missing-executable.exe")))))))

(ert-deftest eglot-test-server-programs-executable-multiple-major-modes ()
  (let ((eglot-server-programs '(((bar-mode foo-mode) "some-executable")))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'eglot-lsp-server))
      (should (equal guessed-contact '("some-executable"))))))

(ert-deftest eglot-test-server-programs-executable-with-arg ()
  (let ((eglot-server-programs '((foo-mode "some-executable" "arg1")))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'eglot-lsp-server))
      (should (equal guessed-contact '("some-executable" "arg1"))))))

(ert-deftest eglot-test-server-programs-executable-with-args-and-autoport ()
  (let ((eglot-server-programs '((foo-mode "some-executable" "arg1"
                                           :autoport "arg2")))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'eglot-lsp-server))
      (should (equal guessed-contact '("some-executable" "arg1"
                                       :autoport "arg2"))))))

(ert-deftest eglot-test-server-programs-host-and-port ()
  (let ((eglot-server-programs '((foo-mode "somehost.example.com" 7777)))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'eglot-lsp-server))
      (should (equal guessed-contact '("somehost.example.com" 7777))))))

(ert-deftest eglot-test-server-programs-host-and-port-and-tcp-args ()
  (let ((eglot-server-programs '((foo-mode "somehost.example.com" 7777
                                           :type network)))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'eglot-lsp-server))
      (should (equal guessed-contact '("somehost.example.com" 7777
                                       :type network))))))

(ert-deftest eglot-test-server-programs-class-name-and-plist ()
  (let ((eglot-server-programs '((foo-mode bar-class :init-key init-val)))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'bar-class))
      (should (equal guessed-contact '(:init-key init-val))))))

(ert-deftest eglot-test-server-programs-class-name-and-contact-spec ()
  (let ((eglot-server-programs '((foo-mode bar-class "some-executable" "arg1"
                                           :autoport "arg2")))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'bar-class))
      (should (equal guessed-contact '("some-executable" "arg1"
                                       :autoport "arg2"))))))

(ert-deftest eglot-test-server-programs-function ()
  (let ((eglot-server-programs '((foo-mode . (lambda (&optional _)
                                               '("some-executable")))))
        (major-mode 'foo-mode))
    (eglot--guessing-contact (_ prompt-args guessed-class guessed-contact)
      (should (not prompt-args))
      (should (equal guessed-class 'eglot-lsp-server))
      (should (equal guessed-contact '("some-executable"))))))

(ert-deftest eglot-test-server-programs-guess-lang ()
  (let ((major-mode 'foo-mode))
    (let ((eglot-server-programs '((foo-mode . ("prog-executable")))))
      (eglot--guessing-contact (_ nil _ _ _ guessed-langs)
        (should (equal guessed-langs '("foo")))))
    (let ((eglot-server-programs '(((foo-mode :language-id "bar")
                                    . ("prog-executable")))))
      (eglot--guessing-contact (_ nil _ _ _ guessed-langs)
        (should (equal guessed-langs '("bar")))))
    (let ((eglot-server-programs '(((baz-mode (foo-mode :language-id "bar"))
                                    . ("prog-executable")))))
      (eglot--guessing-contact (_ nil _ _ modes guessed-langs)
        (should (equal guessed-langs '("bar" "baz")))
        (should (equal modes '(foo-mode baz-mode)))))))

(defun eglot--glob-match (glob str)
  (funcall (eglot--glob-compile glob t t) str))

(ert-deftest eglot-test-glob-test ()
  (should (eglot--glob-match "foo/**/baz" "foo/bar/baz"))
  (should (eglot--glob-match "foo/**/baz" "foo/baz"))
  (should-not (eglot--glob-match "foo/**/baz" "foo/bar"))
  (should (eglot--glob-match "foo/**/baz/**/quuz" "foo/baz/foo/quuz"))
  (should (eglot--glob-match "foo/**/baz/**/quuz" "foo/foo/foo/baz/foo/quuz"))
  (should-not (eglot--glob-match "foo/**/baz/**/quuz" "foo/foo/foo/ding/foo/quuz"))
  (should (eglot--glob-match "*.js" "foo.js"))
  (should-not (eglot--glob-match "*.js" "foo.jsx"))
  (should (eglot--glob-match "foo/**/*.js" "foo/bar/baz/foo.js"))
  (should-not (eglot--glob-match "foo/**/*.js" "foo/bar/baz/foo.jsx"))
  (should (eglot--glob-match "*.{js,ts}" "foo.js"))
  (should-not (eglot--glob-match "*.{js,ts}" "foo.xs"))
  (should (eglot--glob-match "foo/**/*.{js,ts}" "foo/bar/baz/foo.ts"))
  (should (eglot--glob-match "foo/**/*.{js,ts}x" "foo/bar/baz/foo.tsx"))
  (should (eglot--glob-match "?oo.js" "foo.js"))
  (should (eglot--glob-match "foo/**/*.{js,ts}?" "foo/bar/baz/foo.tsz"))
  (should (eglot--glob-match "foo/**/*.{js,ts}?" "foo/bar/baz/foo.tsz"))
  (should (eglot--glob-match "example.[!0-9]" "example.a"))
  (should-not (eglot--glob-match "example.[!0-9]" "example.0"))
  (should (eglot--glob-match "example.[0-9]" "example.0"))
  (should-not (eglot--glob-match "example.[0-9]" "example.a"))
  (should (eglot--glob-match "**/bar/" "foo/bar/"))
  (should-not (eglot--glob-match "foo.hs" "fooxhs"))

  ;; Some more tests
  (should (eglot--glob-match "**/.*" ".git"))
  (should (eglot--glob-match ".?" ".o"))
  (should (eglot--glob-match "**/.*" ".hidden.txt"))
  (should (eglot--glob-match "**/.*" "path/.git"))
  (should (eglot--glob-match "**/.*" "path/.hidden.txt"))
  (should (eglot--glob-match "**/node_modules/**" "node_modules/"))
  (should (eglot--glob-match "{foo,bar}/**" "foo/test"))
  (should (eglot--glob-match "{foo,bar}/**" "bar/test"))
  (should (eglot--glob-match "some/**/*" "some/foo.js"))
  (should (eglot--glob-match "some/**/*" "some/folder/foo.js"))

  ;; VSCode supposedly supports this, not sure if good idea.
  ;;
  ;; (should (eglot--glob-match "**/node_modules/**" "node_modules"))
  ;; (should (eglot--glob-match "{foo,bar}/**" "foo"))
  ;; (should (eglot--glob-match "{foo,bar}/**" "bar"))

  ;; VSCode also supports nested blobs.  Do we care?
  ;;
  ;; (should (eglot--glob-match "{**/*.d.ts,**/*.js}" "/testing/foo.js"))
  ;; (should (eglot--glob-match "{**/*.d.ts,**/*.js}" "testing/foo.d.ts"))
  ;; (should (eglot--glob-match "{**/*.d.ts,**/*.js,foo.[0-9]}" "foo.5"))
  ;; (should (eglot--glob-match "prefix/{**/*.d.ts,**/*.js,foo.[0-9]}" "prefix/foo.8"))
  )

(defvar tramp-histfile-override)
(defun eglot--call-with-tramp-test (fn)
  (unless (>= emacs-major-version 27)
    (ert-skip "Eglot Tramp support only on Emacs >= 27"))
  ;; Set up a Tramp method that’s just a shell so the remote host is
  ;; really just the local host.
  (let* ((tramp-remote-path (cons 'tramp-own-remote-path
                                  tramp-remote-path))
         (tramp-histfile-override t)
         (tramp-allow-unsafe-temporary-files t)
         (tramp-verbose 1)
         (temporary-file-directory
          (or (bound-and-true-p ert-remote-temporary-file-directory)
              (prog1 (format "/mock::%s" temporary-file-directory)
                (add-to-list
                 'tramp-methods
                 '("mock"
                   (tramp-login-program "sh")       (tramp-login-args (("-i")))
                   (tramp-direct-async ("-c"))      (tramp-remote-shell "/bin/sh")
                   (tramp-remote-shell-args ("-c")) (tramp-connection-timeout 10)))
                (add-to-list 'tramp-default-host-alist
                             `("\\`mock\\'" nil ,(system-name)))
                (when (and noninteractive (not (file-directory-p "~/")))
                  (setenv "HOME" temporary-file-directory)))))
         (default-directory temporary-file-directory))
    (funcall fn)))

(ert-deftest eglot-test-tramp-test ()
  "Ensure LSP servers can be used over TRAMP."
  :tags '(:expensive-test)
  (eglot--call-with-tramp-test #'eglot-tests--auto-detect-running-server-1))

(ert-deftest eglot-test-tramp-test-2 ()
  "Ensure LSP servers can be used over TRAMP."
  :tags '(:expensive-test)
  (eglot--call-with-tramp-test #'eglot-tests--lsp-abiding-column-1))

(ert-deftest eglot-test-path-to-uri-windows ()
  (skip-unless (eq system-type 'windows-nt))
  (should (string-prefix-p "file:///"
                           (eglot--path-to-uri "c:/Users/Foo/bar.lisp")))
  (should (string-suffix-p "c%3A/Users/Foo/bar.lisp"
                           (eglot--path-to-uri "c:/Users/Foo/bar.lisp"))))

(ert-deftest eglot-test-same-server-multi-mode ()
  "Check single LSP instance manages multiple modes in same project."
  (skip-unless (executable-find "clangd"))
  (let (server)
    (eglot--with-fixture
        `(("project" . (("foo.cpp" .
                         "#include \"foolib.h\"
                        int main() { return foo(); }")
                        ("foolib.h" .
                         "#ifdef __cplusplus\nextern \"C\" {\n#endif
                        int foo();
                        #ifdef __cplusplus\n}\n#endif")
                        ("foolib.c" .
                         "#include \"foolib.h\"
                        int foo() {return 42;}"))))
      (with-current-buffer
          (eglot--find-file-noselect "project/foo.cpp")
        (should (setq server (eglot--tests-connect))))
      (with-current-buffer
          (eglot--find-file-noselect "project/foolib.h")
        (should (eq (eglot-current-server) server)))
      (with-current-buffer
          (eglot--find-file-noselect "project/foolib.c")
        (should (eq (eglot-current-server) server))))))

(provide 'eglot-tests)

;; Local Variables:
;; checkdoc-force-docstrings-flag: nil
;; End:

;;; eglot-tests.el ends here
