Subject: bug#68246: 30.0.50; Add non-TS mode as extra parent of TS modes

From: Dmitry Gutov <[email-masked]>
Date: Mon, 15 Jan 2024 04:10:16 +0200

On 14/01/2024 09:02, Eli Zaretskii wrote:
>> From: João Távora <[email-masked]>
>> Cc: Eli Zaretskii <[email-masked]>,  Stefan Monnier
>>   <[email-masked]>,  [email-masked]
>> Date: Sun, 14 Jan 2024 03:10:02 +0000
>>
>> Yuan Fu <[email-masked]> writes:
>>
>>> observe what breaks? Then Joao can either say “I told you” or happily
>>> found out that this patch works ok.
>>
>> Those are not the only two outcomes.  It might work OK and not break
>> anything [*].
>>
>> However it is not easy to quantify confused users looking to understand
>> the new meaning of things in dir-locals.el.  Or users wondering why they
>> need to set Eglot variables in both 'c++-mode-hook' and
>> 'c++-ts-mode-hook' when all they see is 'c++-mode' in
>> 'eglot-server-programs'.
> 
> Those users will hopefully submit bug reports or otherwise complain on
> the Emacs mailing lists, and then we will know.

I rather wouldn't rely on that.

>> There are better alternatives to this patch:
>>
>> 1. The base modes, which are substantially _already_ in place.  They
>>     follow the naming convention <lang>-base-mode.  After giving more
>>     thought to your earlier objection about derived modes overriding
>>     variables, it doesn't make sense (I can elaborate if you want :-) ).
> 
> The recommendation is to use base modes where it makes sense, and the
> installed changes around derived-mode-add-parents don't in any way
> preclude having a base mode and don't make it harder.  But I don't
> think we should force everyone in this situation to invent a base mode
> as the sole means for solving this.

It's not like we don't have an existing solution for this: if there are 
two different modes to configure, change the settings for both modes, or 
alter two hooks. Less magical and more verbose, but being explicit can 
be good.

>> 2. Explicitly associating some major modes with languages or file types.
>>     This doesn't seem hard and other further uses like suggesting modes
>>     or packages to a new user based on languages have been proposed.
> 
> This is IMO a heavier and more thorough change, especially since Emacs
> doesn't have the notion of "language".  This discussion shows that its
> advantages are not evident, and moreover we don't even have a clear
> shared view what will that entail.

Here's a draft patch of how a "language" could work. It doesn't alter 
every entry, but it is backward compatible.

It adds an entity called "language" above major modes which are denoted 
with keywords (you could also call it content-types, but that's a longer 
name). The patch implements an implicit language detection as well 
(based on the mode name), but ideally modes that belong to specific 
languages would not have direct entries in auto-mode-alist, etc, at all.

Benefits:
- Avoiding duplicating a bunch of regexps, for ts modes in particular.
- Uncovered an existing bug: ruby-ts-mode didn't add an entry for 
interpreter-mode-alist, only for auto-mode-alist.
- The user could avoid thinking in terms of major modes, and when they 
wanted to enable a fitting mode, they can type 'M-x set-buffer-language' 
and choose one of the known languages with completion.
- Features like Eglot could now call (buffer-language) and dispatch 
based on that (that can make the value of eglot-server-programs more 
compact in the long run).
- Hook <lang>-language-hook is run inside set-auto-mode-0, for the user 
to add customizations to major modes that share the language but no 
common ancestor.

Further possible additions (mentioned previously):
- Potential UI for customizing major-mode-remap-alist to decide which 
major mode to use for a given language, becomes easier/doable.
- Even when there is no installed major mode for a given language, but 
an auto-mode-alist entry for it exists, we could now do something with 
it in fundamental-mode. Like still use Eglot's features, or suggest the 
user installs one of the language support packages for that language 
from ELPA (knowing the language name, we can suggest a specific package).

diff --git a/lisp/files.el b/lisp/files.el
index 9c8914bfc50..0707dc09ac8 100644
--- a/lisp/files.el
+++ b/lisp/files.el
@@ -3010,10 +3010,10 @@ auto-mode-alist
      ("\\.dbk\\'" . xml-mode)
      ("\\.dtd\\'" . sgml-mode)
      ("\\.ds\\(ss\\)?l\\'" . dsssl-mode)
-     ("\\.js[mx]?\\'" . javascript-mode)
+     ("\\.js[mx]?\\'" . :js)
      ;; https://en.wikipedia.org/wiki/.har
-     ("\\.har\\'" . javascript-mode)
-     ("\\.json\\'" . js-json-mode)
+     ("\\.har\\'" . :js)
+     ("\\.json\\'" . :json)
      ("\\.[ds]?va?h?\\'" . verilog-mode)
      ("\\.by\\'" . bovine-grammar-mode)
      ("\\.wy\\'" . wisent-grammar-mode)
@@ -3150,6 +3150,9 @@ auto-mode-alist
 Visiting a file whose name matches REGEXP specifies FUNCTION as the
 mode function to use.  FUNCTION will be called, unless it is nil.
 
+FUNCTION can also be a keyword denoting a language, to be looked
+up in `major-mode-remap-alist'.
+
 If the element has the form (REGEXP FUNCTION NON-NIL), then after
 calling FUNCTION (if it's not nil), we delete the suffix that matched
 REGEXP and search the list again for another match.
@@ -3206,10 +3209,10 @@ interpreter-mode-alist
      ("emacs" . emacs-lisp-mode)))
   "Alist mapping interpreter names to major modes.
 This is used for files whose first lines match `auto-mode-interpreter-regexp'.
-Each element looks like (REGEXP . MODE).
+Each element looks like (REGEXP . MODE-OR-LANGUAGE).
 If REGEXP matches the entire name (minus any directory part) of
 the interpreter specified in the first line of a script, enable
-major mode MODE.
+MODE-OR-LANGUAGE.
 
 See also `auto-mode-alist'.")
 
@@ -3554,7 +3557,7 @@ major-mode-remap-alist
 ;; same one we already have, don't actually reset it.  We don't want to lose
 ;; minor modes such as Font Lock.
 (defun set-auto-mode-0 (mode &optional keep-mode-if-same)
-  "Apply MODE and return it.
+  "Apply MODE and return it.  MODE can be a function of language.
 If optional arg KEEP-MODE-IF-SAME is non-nil, MODE is chased of
 any aliases and compared to current major mode.  If they are the
 same, do nothing and return nil."
@@ -3565,11 +3568,43 @@ set-auto-mode-0
 		        (eq mode (car set-auto-mode--last))
 		        (eq major-mode (cdr set-auto-mode--last)))))
     (when mode
-      (funcall (alist-get mode major-mode-remap-alist mode))
+      ;; XXX: When there's no mapping for `:<language>', we could also
+      ;; look for a function called `<language>-mode'.
+      (funcall (alist-get mode major-mode-remap-alist (if (keywordp mode)
+                                                          #'fundamental-mode
+                                                        mode)))
+      (when (keywordp mode)             ;Perhaps do that unconditionally.
+        (run-hooks (intern (format "%s-language-hook" (buffer-language)))))
       (unless (eq mode major-mode)
         (setq set-auto-mode--last (cons mode major-mode)))
       mode)))
 
+(defun buffer-language ()
+  "Return the language of the current buffer.
+A language is a lowercase keyword with the name of the language."
+  ;; Alternatively, we could go through all the matchers in
+  ;; auto-mode-alist, interpreter-mode-alist,
+  ;; magic-fallback-mode-alist here, possibly using a cache keyed on
+  ;; buffer-file-name.  But that's probably an overkill: if the user
+  ;; changes the settings, they can call `M-x revert-buffer' at the end.
+  (if (keywordp (car set-auto-mode--last))
+      (car set-auto-mode--last)
+    ;; Backward compatibility.
+    (intern (format ":%s" (replace-regexp-in-string "\\(?:-ts\\)?-mode\\'" ""
+                                                    (symbol-name major-mode))))))
+
+(defun set-buffer-language (language)
+  "Set the language of the current buffer.
+And switch the major mode appropriately."
+  (interactive
+   (list (let* ((ct (mapcan
+                     (lambda (pair) (and (keywordp (car pair))
+                                    (list (symbol-name (car pair)))))
+                     major-mode-remap-alist))
+                (lang (completing-read "Language: " ct)))
+           (and lang (intern lang)))))
+  (set-auto-mode-0 language))
+
 (defvar file-auto-mode-skip "^\\(#!\\|'\\\\\"\\)"
   "Regexp of lines to skip when looking for file-local settings.
 If the first line matches this regular expression, then the -*-...-*- file-
diff --git a/lisp/progmodes/js.el b/lisp/progmodes/js.el
index 0115feb0e97..218a0f05541 100644
--- a/lisp/progmodes/js.el
+++ b/lisp/progmodes/js.el
@@ -3895,8 +3895,7 @@ js-ts-mode
                    nil nil)))
     (treesit-major-mode-setup)
 
-    (add-to-list 'auto-mode-alist
-                 '("\\(\\.js[mx]\\|\\.har\\)\\'" . js-ts-mode))))
+    (add-to-list 'major-mode-remap-alist '(:js . js-ts-mode))))
 
 (defvar js-ts--s-p-query
   (when (treesit-available-p)
@@ -3977,7 +3976,11 @@ js-jsx--comment-region
 
 ;;;###autoload
 (dolist (name (list "node" "nodejs" "gjs" "rhino"))
-  (add-to-list 'interpreter-mode-alist (cons (purecopy name) 'js-mode)))
+  (add-to-list 'interpreter-mode-alist (cons (purecopy name) :js)))
+
+;;;###autoload
+(add-to-list 'major-mode-remap-alist '(:js . js-mode))
+(add-to-list 'major-mode-remap-alist '(:json . js-json-mode))
 
 (provide 'js)
 
diff --git a/lisp/progmodes/json-ts-mode.el b/lisp/progmodes/json-ts-mode.el
index 32bc10bbda9..453554a92dd 100644
--- a/lisp/progmodes/json-ts-mode.el
+++ b/lisp/progmodes/json-ts-mode.el
@@ -165,8 +165,7 @@ json-ts-mode
   (treesit-major-mode-setup))
 
 (if (treesit-ready-p 'json)
-    (add-to-list 'auto-mode-alist
-                 '("\\.json\\'" . json-ts-mode)))
+    (add-to-list 'major-mode-remap-alist '(:json . json-ts-mode)))
 
 (provide 'json-ts-mode)
 
diff --git a/lisp/progmodes/python.el b/lisp/progmodes/python.el
index 1148da11a06..0d560ee1a5f 100644
--- a/lisp/progmodes/python.el
+++ b/lisp/progmodes/python.el
@@ -288,9 +288,9 @@ outline-heading-end-regexp
 (autoload 'help-function-arglist "help-fns")
 
 ;;;###autoload
-(add-to-list 'auto-mode-alist (cons (purecopy "\\.py[iw]?\\'") 'python-mode))
+(add-to-list 'auto-mode-alist (cons (purecopy "\\.py[iw]?\\'") :python))
 ;;;###autoload
-(add-to-list 'interpreter-mode-alist (cons (purecopy "python[0-9.]*") 'python-mode))
+(add-to-list 'interpreter-mode-alist (cons (purecopy "python[0-9.]*") :python))
 
 (defgroup python nil
   "Python Language's flying circus support for Emacs."
@@ -6992,8 +6992,7 @@ python-ts-mode
     (when python-indent-guess-indent-offset
       (python-indent-guess-indent-offset))
 
-    (add-to-list 'auto-mode-alist '("\\.py[iw]?\\'" . python-ts-mode))
-    (add-to-list 'interpreter-mode-alist '("python[0-9.]*" . python-ts-mode))))
+    (add-to-list 'major-mode-remap-alist '(:python . python-ts-mode))))
 
 ;;; Completion predicates for M-x
 ;; Commands that only make sense when editing Python code.
diff --git a/lisp/progmodes/ruby-mode.el b/lisp/progmodes/ruby-mode.el
index 999fbebfb08..6975cf5be0a 100644
--- a/lisp/progmodes/ruby-mode.el
+++ b/lisp/progmodes/ruby-mode.el
@@ -2703,11 +2703,13 @@ ruby-mode
                                      "\\|Puppet\\|Berks\\|Brew\\|Fast"
                                      "\\|Vagrant\\|Guard\\|Pod\\)file"
                                      "\\)\\'"))
-                   'ruby-mode))
+                   :ruby))
+;;;###autoload
+(add-to-list 'major-mode-remap-alist '(:ruby . ruby-mode))
 
 ;;;###autoload
 (dolist (name (list "ruby" "rbx" "jruby" "j?ruby\\(?:[0-9.]+\\)"))
-  (add-to-list 'interpreter-mode-alist (cons (purecopy name) 'ruby-mode)))
+  (add-to-list 'interpreter-mode-alist (cons (purecopy name) :ruby)))
 
 (provide 'ruby-mode)
 
diff --git a/lisp/progmodes/ruby-ts-mode.el b/lisp/progmodes/ruby-ts-mode.el
index 598eaa461ff..9fb253ba706 100644
--- a/lisp/progmodes/ruby-ts-mode.el
+++ b/lisp/progmodes/ruby-ts-mode.el
@@ -35,8 +35,8 @@
 ;; `treesit-extra-load-path'.
 
 ;; This mode doesn't associate itself with .rb files automatically.
-;; You can do that either by prepending to the value of
-;; `auto-mode-alist', or using `major-mode-remap-alist'.
+;; You can do that using `major-mode-remap-alist' (by adding an entry
+;; for `:ruby').
 
 ;; Tree Sitter brings a lot of power and versitility which can be
 ;; broken into these features.
@@ -1197,18 +1197,7 @@ ruby-ts-mode
   (setq-local syntax-propertize-function #'ruby-ts--syntax-propertize))
 
 (if (treesit-ready-p 'ruby)
-    ;; Copied from ruby-mode.el.
-    (add-to-list 'auto-mode-alist
-                 (cons (concat "\\(?:\\.\\(?:"
-                               "rbw?\\|ru\\|rake\\|thor"
-                               "\\|jbuilder\\|rabl\\|gemspec\\|podspec"
-                               "\\)"
-                               "\\|/"
-                               "\\(?:Gem\\|Rake\\|Cap\\|Thor"
-                               "\\|Puppet\\|Berks\\|Brew"
-                               "\\|Vagrant\\|Guard\\|Pod\\)file"
-                               "\\)\\'")
-                       'ruby-ts-mode)))
+    (add-to-list 'major-mode-remap-alist '(:ruby . ruby-ts-mode)))
 
 (provide 'ruby-ts-mode)