Skip to content

Fix Emacs 31 breakage and a pile of refactoring bugs - #161

Merged
bbatsov merged 12 commits into
mainfrom
fix-emacs31-and-refactorings
Oct 26, 2026
Merged

bbatsov merged 12 commits into
mainfrom
fix-emacs31-and-refactorings

Conversation

@bbatsov

@bbatsov bbatsov commented Oct 26, 2026

Copy link
Copy Markdown
Member

Emacs 31 broke clojure-ts-clojurescript-mode (it prompts for the jsdoc grammar and won't start without it) and made M-q reindent only the innermost form. The cljs and jank modes also lost our setup overrides when they reran treesit-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

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
bbatsov force-pushed the fix-emacs31-and-refactorings branch from a8c6c27 to 7c09846 Compare October 26, 2026 16:32
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
bbatsov force-pushed the fix-emacs31-and-refactorings branch from 7c09846 to 09991b0 Compare October 26, 2026 16:50
@bbatsov
bbatsov merged commit 9aed091 into main Oct 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

set clojure-ts-align-forms-automatically not working when hit TAB in clojure-ts-clojurescript-mode

1 participant