Repository navigation
Fix Emacs 31 breakage and a pile of refactoring bugs - #161
Merged
Merged
Conversation
The ClojureScript mode borrows js-ts-mode's font-lock rules by running it in a temp buffer. On Emacs 31 js-ts-mode asks to install the jsdoc grammar, and when it's missing its font-lock rules for jsdoc fail to compile in treesit-major-mode-setup, so every .cljs visit either prompted or ended with a treesit-load-language-error. The harvest now runs the mode with grammar installation turned off, keeps the settings it set up before failing, and drops the rules for languages that aren't available, which our own treesit-major-mode-setup would otherwise trip over.
To pick up js-ts-mode's and c++-ts-mode's font-lock rules we run those modes in a temp buffer, which also ran their hooks and prog-mode-hook. Whatever users hang there (eglot-ensure, lsp, linters) fired for a hidden buffer every time a ClojureScript or Jank file was opened.
clojure-ts-mode overrides a few things treesit-major-mode-setup sets, indent-region-function among them when clojure-ts-align-forms-automatically is on. The ClojureScript and Jank modes run the setup a second time after adding their embedded parser, which quietly undid those overrides, so indenting a region never aligned anything there. The overrides now live in a helper that every setup run goes through.
Emacs 31's treesit-major-mode-setup sets prog-fill-reindent-defun-function to treesit-fill-reindent-defun, which reindents the innermost defun. Every form counts as a defun for us, so M-q in code ended up reindenting just the form around point. Going back to the default implementation restores the Emacs 30 behaviour: reindent the top-level form, or fill when point is in a string or comment.
clojure-ts-update-ns relies on clojure-ts-find-ns to leave the ns name in the match data, but a cached ns is returned before any match data is set. With clojure-ts-cache-ns on, replace-match then went with whatever the last search happened to match, e.g. a string somewhere in the buffer, while the ns form stayed as it was. update-ns now always looks the ns form up, so find-ns no longer needs to clobber the caller's match data.
clojure-ts-cycle-privacy searched for "def" from the start of the top-level form without a bound. In a form that isn't a definition the search carried on into the next one, so with point in the ns form the following defn became private. It also matched names that merely start with "def": in (ns my.default) the ns name got a "-" spliced in. The command now looks at the head symbol of the form and signals a user-error unless it's a def form that names a symbol, which also leaves spec definitions like (s/def ::foo int?) alone.
clojure-ts-cycle-conditional renamed the head by deleting as many characters after the paren as the unqualified name has, so (clojure.core/if-not a b c) came out as (ife.core/if-not b a c). The branches were swapped with forward-sexp and transpose-sexps, which treat the #_ of a discarded form and the ^ of metadata as sexps of their own, so (if a #_x b c) swapped the discard with b. An if without an else branch was left half edited, with a scan-error, and #(if ...) wasn't recognised at all because the head lookup ignored function literals. The command now replaces the name node of the head symbol and swaps the branch nodes, metadata included. A missing else branch becomes an explicit nil, which keeps the meaning of the form intact. Branches are never swapped around a reader conditional, since it can stand for several forms or none.
bbatsov
force-pushed
the
fix-emacs31-and-refactorings
branch
from
October 26, 2026 16:32
a8c6c27 to
7c09846
Compare
Removing a not raised the inner form starting at the form itself, but in this grammar metadata is a sibling in front of the form, so (not ^:x (foo a)) became (foo a). Adding a not had the mirror image problem: it wrapped the form without its metadata, which ended up on the not, so ^:x (foo a) became ^:x (not (foo a)). Both directions now treat the metadata as part of the form. The command also kept the start of the form as a plain position for reindenting. Unwrapping deletes text before it, so with a comment or a qualified clojure.core/not in the way it pointed past the end of the buffer and indent-region signalled args-out-of-range. It also found where a form starts with a helper written for the old grammar, which took metadata on the first element for metadata on the form itself: (^String foo a) turned into ((not ^String) foo a). Forms start where their node starts now, and the helper is gone.
The threading commands find their way around with sexp motion, which treats the ^ of metadata and the #_ of a discarded form as sexps of their own. That splits them from the forms they belong to: (foo ^String (bar x) 1) thread-first-all (-> String (foo (bar x) 1)) (-> x #_(foo) (inc)) unwind-all (inc (foo (#_ x))) (-> x (^String foo bar)) unwind (^String x foo bar) The second one even turns discarded code into live code. Reader conditionals don't survive the trip either, since they can stand for several forms or none: (-> x #?(:clj (inc))) unwound to (#?(:clj (inc)) x), and threading #?@(:clj [x y]) out of an argument list made both x and y threaded values. Until the commands work on the syntax tree, they check the forms they're about to take apart and signal a user-error instead. Only the part of a form a command actually moves things around in gets checked, so metadata on an argument that stays put doesn't get in the way. Fully threading a form checks every form it would split before it starts, so it either goes all the way or leaves the buffer alone. Fully threading no longer looks the form up as the list thing at point. Emacs 30 takes the symbol list for the function, which matches any node, so with point on an opening paren it got the paren back and the check had nothing to look at.
clojure-ts-add-arity took the first vector or list in the form for the argument vector. In a defmethod that dispatches on a vector, that's the dispatch value, so (defmethod foo [:a :b] [x] x) became (defmethod foo ([]) ([:a :b] [x] x)). The search now starts after the dispatch value.
When point isn't on any form, treesit-node-at returns the next node, or at the end of the buffer the last one, however far before point it ends. The refactoring commands took that as the node at point, so running one from a blank line between forms changed the form below, and from the empty lines after the last form it changed that one: unwinding it, making it private, converting it, and so on. They now look nodes up through a helper that only accepts a node whose top-level form spans point. Point right after the closing paren still counts as being on the form.
In '^:foo (1 2) the metadata sits between the quote and the list. The collection conversion commands wrapped whatever followed the quote, so converting to a vector left [^:foo] (1 2) behind and then failed with "Not before matching pair". They now wrap the list itself. Converting to a quoted list had the opposite problem: the quote went right before the collection, after its metadata, so ^:foo [1 2] became ^:foo '(1 2). That puts the metadata on the quote form, which evaluates away, and the list ends up without it. The quote now goes in front of the metadata.
bbatsov
force-pushed
the
fix-emacs31-and-refactorings
branch
from
October 26, 2026 16:50
7c09846 to
09991b0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Emacs 31 broke
clojure-ts-clojurescript-mode(it prompts for the jsdoc grammar and won't start without it) and madeM-qreindent only the innermost form. The cljs and jank modes also lost our setup overrides when they rerantreesit-major-mode-setup, and ran the hooks of the modes they borrow font-lock rules from in a hidden buffer.The rest are refactoring commands that mangled code around metadata,
#_and reader conditionals, or edited a nearby form when point was on a blank line. The threading commands just refuse those cases for now. Handling them properly means moving them off sexp motion and onto the syntax tree, which is its own job.Fixes #147