;;; ltex-eglot.el --- LTeX support for Eglot. -*- lexical-binding: t -*- ;;; Commentary: ;;; Code: (require 'eglot) (defconst ltex-eglot-supported-languages '("ar" "ast-ES" "be-BY" "br-FR" "ca-ES" "ca-ES-valencia" "da-DK" "de" "de-AT" "de-CH" "de-DE" "de-DE-x-simple-language" "el-GR" "en" "en-AU" "en-CA" "en-GB" "en-NZ" "en-US" "en-ZA" "eo" "es" "es-AR" "fa" "fr" "ga-IE" "gl-ES" "it" "ja-JP" "km-KH" "nl" "nl-BE" "pl-PL" "pt" "pt-AO" "pt-BR" "pt-MZ" "pt-PT" "ro-RO" "ru-RU" "sk-SK" "sl-SI" "sv" "ta-IN" "tl-PH" "uk-UA" "zh-CN") "List of languages supportd by LTeX.") (defcustom ltex-eglot-server-binary "ltex-ls" "The binary to use for the LTeX LSP server." :group 'ltex-eglot :type 'string) (defconst ltex-eglot-modes ;; Source: ;; https://github.com/emacs-languagetool/eglot-ltex/blob/master/eglot-ltex.el '((org-mode :language-id "org") (git-commit-elisp-text-mode :language-id "gitcommit") (bibtex-mode :language-id "bibtex") (context-mode :language-id "context") (latex-mode :language-id "latex") (LaTeX-mode :language-id "latex") (markdown-mode :language-id "markdown") (rst-mode :language-id "restructuredtext") (text-mode :language-id "plaintext")) "List of major mode that work with LanguageTool.") (defcustom ltex-eglot-mother-tounge "en-US" "The user's native language." :group 'ltex-eglot :type '(string :tag "Language Code")) (defcustom ltex-eglot-language ltex-eglot-mother-tounge "The main language to use when checking documents." :group 'ltex-eglot :type '(choice :tag "Language" (const :tag "Detect Automatically" "auto") (string :tag "Language Code")) :set-after '(ltex-eglot-mother-tounge) :safe 'stringp) (defcustom ltex-eglot-enable-spell-check nil "Weather or not to enable spell checking with LTeX." :group 'ltex-eglot :type '(choice :tag "Status" (const :tag "Enabled" t) (const :tag "Disabled" nil))) (defcustom ltex-eglot-spell-check-rules '(:en-US ["EN_CONTRACTION_SPELLING" "MORFOLOGIK_RULE_EN_US"]) "Rules to disable if `ltex-eglot-enable-spell-check' is nil." :group 'ltex-eglot :type '(plist :tag "Entries by language" :key-type (string :tag "Language Code") :value-type (repeat :tag "Rules" string))) (defun ltex-eglot--entry-file-p (entry) "Check if ENTRY would be concidered a file by LTex LSP." (when (stringp entry) (string-prefix-p ":" entry))) (defun ltex-eglot--non-file-settings-plist-p (plist) "Return non-nil if none of the values of PLIST refer to files. This is meant to check file-local saftey for the likes of `ltex-eglot-disabled-rules'." (cl-loop for (_ entries) on plist by 'cddr when (cl-some 'ltex-eglot--entry-file-p entries) do (cl-return) finally return t)) (defcustom ltex-eglot-disabled-rules () "List of diagnostic rules to disable." :group 'ltex-eglot :type '(plist :tag "Entries by language" :key-type (string :tag "Language Code") :value-type (repeat :tag "Rules" string)) :safe 'ltex-eglot--non-file-settings-plist-p) (defcustom ltex-eglot-enabled-rules () "List of diagnostic rules to enable." :group 'ltex-eglot :type '(plist :tag "Entries by language" :key-type (string :tag "Language Code") :value-type (repeat :tag "Rules" string)) :safe 'ltex-eglot--non-file-settings-plist-p) (defcustom ltex-eglot-dictionary () "List of words in the LTeX dictionary." :group 'ltex-eglot :type '(plist :tag "Entries by language" :key-type (string :tag "Language Code") :value-type (repeat :tag "Words" string)) :safe 'ltex-eglot--non-file-settings-plist-p) (defun ltex-eglot--valid-latex-environments-p (plist) "Check if PLIST is an OK value for the `ltex-eglot-latex-environemnts'." (cl-loop for (name handling) on plist by 'cddr unless (and (stringp name) (member handling '("ignore" "default"))) do (cl-return) finally return t)) (defcustom ltex-eglot-latex-environments () "Plist controlling the handling of LaTeX environments." :group 'ltex-eglot :type '(plist :tag "Environments" :key-type (string :tag "Name") :value-type (choice :tag "Handling" (const :tag "Ignore" "ignore") (const :tag "Check" "default"))) :safe 'ltex-eglot--valid-latex-plist-p) (defun ltex-eglot--valid-latex-commands-p (plist) "Check if PLIST is an OK value for the `ltex-eglot-latex-commands'." (cl-loop for (name handling) on plist by 'cddr unless (and (stringp name) (member handling '("ignore" "default" "dummy" "pluralDummy" "vowelDummy"))) do (cl-return) finally return t)) (defcustom ltex-eglot-latex-commands () "Plist controlling the handling of LaTeX commands." :group 'ltex-eglot :type '(plist :tag "Commands" :key-type (string :tag "Name") :value-type (choice :tag "Handling" (const :tag "Default" "default") (const :tag "Ignore" "ignore") (const :tag "Replace with dummy word" "dummy") (const :tag "Replace with dummy plural word" "pluralDummy") (const :tag "Replace with dummy vowel word" "vowelDummy"))) :safe 'ltex-eglot--valid-latex-plist-p) (defun ltex-eglot--valid-bibtex-plist-p (plist) "Return non-nil if PLIST is an OK value for BibTeX options." (cl-loop for (name handling) on plist by 'cddr unless (and (stringp name) (booleanp handling)) do (cl-return) finally return t)) (defcustom ltex-eglot-bibtex-fields () "Plist controlling the handling of BibTeX fields." :group 'ltex-eglot :type '(plist :tag "Fields" :key-type (string :tag "Name") :value-type (choice :tag "Handling" (const :tag "Ignore" nil) (const :tag "Check" t))) :safe 'ltex-eglot--valid-bibtex-plist-p) (defcustom ltex-eglot-enable-picky-rules nil "Weather or not to enable picky rules." :group 'ltex-eglot :type '(choice :tag "Status" (const :tag "Enabled" t) (const :tag "Disabled" nil)) :safe 'booleanp) (defcustom ltex-eglot-variable-save-method 'dir "How to save variables added by quick fixes. This is one of the following: - \\='dir\tSave in .dir-locals.el - \\='file\tSave as a file local variable - nil\tJust set the buffer local value, don't save the variable" :group 'ltex-eglot :type '(choice :tag "Save method" (const :tag "Directory local (saved)" dir) (const :tag "File local (saved)" file) (const :tag "Buffer local (not saved)" nil)) :safe 'symbolp) (defvar ltex-eglot-hidden-false-positives nil "List of hidden false positives. This is intented to be set from .dir-locals.el.") (put 'ltex-eglot-hidden-false-positives 'safe-local-variable 'ltex-eglot--non-file-settings-plist-p) (defun ltex-eglot--merge-options-plists (value-type &rest lists) "Merge each of the options plist LISTS. The values of each of the props can be any sequence, and will be converted to VALUE-TYPE. Any keys will be converted to keyword symbols if they are strings." (let ((output)) (dolist (list lists output) (cl-loop for (prop value) on list by 'cddr for norm-prop = (if (stringp prop) (intern (concat ":" prop)) prop) do (setf (plist-get output norm-prop) (cl-coerce (seq-uniq (seq-concatenate 'list (plist-get output norm-prop) value)) value-type)))))) (defun ltex-eglot--process-and-add-global (global &rest lists) "Merge each of LISTS with `ltex-eglot--merge-options-plists'. If the result of the merger results in a list with the key t, merge GLOBAL in as well." (let ((merged (apply 'ltex-eglot--merge-options-plists 'vector lists))) (cl-loop with found-t = nil for (prop value) on merged by 'cddr when (eq prop t) do (setq found-t t) else collect prop into output and collect value into output finally return (if found-t (ltex-eglot--merge-options-plists 'vector output global) output)))) (defun ltex-eglot--make-plist-props-symbols (plist) "Make each of PLIST's props a symbol by calling `intern' on it." (cl-loop for (prop value) on plist by 'cddr collect (if (stringp prop) (intern (concat ":" prop)) prop) collect value)) (defun ltex-eglot--process-bibtex-fields-plist (plist) "Process a PLIST that might be `ltex-eglot-bibtex-fields'." (cl-loop for (prop value) on plist by 'cddr collect (if (stringp prop) (intern (concat ":" prop)) prop) collect (or value :json-false))) ;; The ltex server doesn't work with eglot when running in standard io mode (defclass ltex-eglot-server (eglot-lsp-server) ((setup-done-p :initform nil :accessor ltex-eglot-server--setup-done-p) (hidden-positives :initform nil :accessor ltex-eglot-server--hidden-positives) (dictionary :initform nil :accessor ltex-eglot-server--dictionary) (disabled-rules :initform nil :accessor ltex-eglot-server--disabled-rules) (language :initform nil :accessor ltex-eglot-server--language)) "LTeX server class.") (cl-defmethod ltex-eglot--disabled-rules-plist ((server ltex-eglot-server)) "Create a plist of disabled rules by language. SERVER is the server from which to get the rules." (ltex-eglot--process-and-add-global (default-value 'ltex-eglot-disabled-rules) (ltex-eglot-server--disabled-rules server) (and (not ltex-eglot-enable-spell-check) ltex-eglot-spell-check-rules))) (cl-defmethod ltex-eglot--setup-server ((server ltex-eglot-server)) "Setup up SERVER for the first time." ;; make sure that dir local stuff is picked up (save-current-buffer (when-let ((buf (cl-first (eglot--managed-buffers server)))) (set-buffer buf)) (setf ;; merger of global values is mediated elsewhere (ltex-eglot-server--hidden-positives server) (if (local-variable-p 'ltex-eglot-hidden-false-positives) ltex-eglot-hidden-false-positives '(t)) (ltex-eglot-server--disabled-rules server) (if (local-variable-p 'ltex-eglot-disabled-rules) ltex-eglot-disabled-rules '(t)) (ltex-eglot-server--dictionary server) (if (local-variable-p 'ltex-eglot-dictionary) ltex-eglot-dictionary '(t)) (ltex-eglot-server--language server) ltex-eglot-language (ltex-eglot-server--setup-done-p server) t))) (cl-defmethod ltex-eglot--build-workspace-settings-plist ((server ltex-eglot-server)) "Build the workspace settings plist for SERVER." (unless (ltex-eglot-server--setup-done-p server) (ltex-eglot--setup-server server)) (list :language (ltex-eglot-server--language server) :dictionary (ltex-eglot--process-and-add-global (default-value 'ltex-eglot-dictionary) (ltex-eglot-server--dictionary server)) :disabledRules (ltex-eglot--disabled-rules-plist server) :enabledRules (ltex-eglot--merge-options-plists 'vector ltex-eglot-enabled-rules) :hiddenFalsePositives (ltex-eglot--process-and-add-global (default-value 'ltex-eglot-hidden-false-positives) (ltex-eglot-server--hidden-positives server)) :latex (list :commands (ltex-eglot--make-plist-props-symbols ltex-eglot-latex-commands) :environments (ltex-eglot--make-plist-props-symbols ltex-eglot-latex-environments)) :bibtex (list :fields (ltex-eglot--process-bibtex-fields-plist ltex-eglot-bibtex-fields)) :additionalRules (list :motherTongue ltex-eglot-mother-tounge :enablePickyRules (or ltex-eglot-enabled-rules :json-false)))) (defun ltex-eglot--cleanup-plist-for-dir-locals (plist) "Cleanup PLIST for use in a .dir-locals.el file." (cl-loop with has-global = nil for (prop value) on plist by 'cddr when (eq prop t) do (setq has-global t) else collect prop into output and collect value into output finally (when has-global (cl-callf nconc output (list t))) finally return output)) (cl-defmethod ltex-eglot--set-variable ((server ltex-eglot-server) variable value) "Set VARIABLE to VALUE in each buffer for SERVER. Also, maybe save VARIABLE in .dir-locals.el or as a file local variable." (cl-case ltex-eglot-variable-save-method (dir (add-dir-local-variable nil variable value)) (file (add-file-local-variable variable value))) (dolist (buf (eglot--managed-buffers server)) (setf (buffer-local-value variable buf) value))) (defun ltex-eglot--handle-client-action (server command slot) "Handle the client side action COMMAND for SERVER. SLOT is a slot in SERVER." (let* ((arg (cl-case slot (disabled-rules :ruleIds) (hidden-positives :falsePositives) (dictionary :words))) (local-var (cl-case slot (disabled-rules 'ltex-eglot-disabled-rules) (hidden-positives 'ltex-eglot-hidden-false-positives) (dictionary 'ltex-eglot-dictionary))) (args (elt (plist-get command :arguments) 0)) (newval (ltex-eglot--merge-options-plists 'list (slot-value server slot) (plist-get args arg)))) (setf (slot-value server slot) newval) (ltex-eglot--set-variable server local-var newval) (eglot-signal-didChangeConfiguration server))) (cl-defmethod eglot-execute ((server ltex-eglot-server) action) "Handler for LTeX actions. ACTION is the action which to run on SERVER." (let ((kind (plist-get action :kind))) (pcase kind ("quickfix.ltex.disableRules" (ltex-eglot--handle-client-action server (plist-get action :command) 'disabled-rules)) ("quickfix.ltex.hideFalsePositives" (ltex-eglot--handle-client-action server (plist-get action :command) 'hidden-positives)) ("quickfix.ltex.addToDictionary" (ltex-eglot--handle-client-action server (plist-get action :command) 'dictionary)) (_ (cl-call-next-method))))) (defun ltex-eglot--hack-server-config (oldfun server &optional path) "Hack the config for SERVER into the return of ODLFUN. PATH is the same as for OLDFUN, which is probably `eglot--workspace-configuration-plist'." (let ((conf (funcall oldfun server path))) (when (ltex-eglot-server-p server) (let ((ltex-conf (plist-get conf :ltex))) (cl-loop for (prop val) on (ltex-eglot--build-workspace-settings-plist server) by 'cddr unless (plist-member ltex-conf prop) do (setf (plist-get ltex-conf prop) val)) (setf (plist-get conf :ltex) ltex-conf))) conf)) (defun ltex-eglot-set-language (language server &optional no-save) "Set the SERVER's language to LANGUAGE. When called interactively, prompt for LANGUAGE. With NO-SAVE, don't save the language setting in any file." (interactive (list (completing-read "Language" ltex-eglot-supported-languages) (eglot-current-server) current-prefix-arg)) (unless (ltex-eglot-server-p server) (user-error "Current server is not an LTeX server!")) (when-let ((server (eglot-current-server))) (setf (ltex-eglot-server--language server) language) (let ((ltex-eglot-variable-save-method (and (not no-save) ltex-eglot-variable-save-method))) (ltex-eglot--set-variable server 'ltex-eglot-language language)) (eglot-signal-didChangeConfiguration server))) ;;;###autoload (add-to-list 'eglot-server-programs (cons ltex-eglot-modes (list 'ltex-eglot-server ltex-eglot-server-binary "--server-type" "TcpSocket" "--no-endless" "--port" :autoport))) ;;;###autoload (advice-add 'eglot--workspace-configuration-plist :around 'ltex-eglot--hack-server-config) (provide 'ltex-eglot) ;;; ltex-eglot.el ends here