2020-11-16 Putting punctuation after closing parens automatically

I (obviously) use Emacs a lot for programming. While I would be quite happy to code in Lisp all the time, the reality is the reality, and I do mostly JavaScript.

(Warning: rant time. You might want to skip the next paragraph.)

And you know what? I don’t really get all the hate about JS. After all, it pays my bills. ;-) But seriously, yes, its syntax is broken here and there, its standard library sucks, and the comunity’s NIH attitude (it’s a day off today, what do I do? I know, I do a new JS framework!) is perhaps a bit childish. But every language has its share of issues, and while JS is far from perfect – maybe even in the lower part of the spectrum, though I doubt it – it has reasonable semantics (after all, it’s a Scheme at heart, only with bad syntax), it has a clever object system, it improves every day, and it has pretty good tooling. And – last but not least – I’d choose a good team using a bad language over a bad team using a good language every single day, and happily, I don’t have to, ‘cause that’s what I have already.

OK, enough ranting. Here’s the thing: there are some things that annoy me in my daily work. One of them is this. Assume that I define a function called main, and I want to, well, call it. I type the letters m, a, i and n, followed by an opening paren, and Emacs with its Smartparens mode inserts the closing paren for me (but leaves the point between the parens, so that I can type in the arguments).

So far, so good. But I also happen to use Flycheck, and it uses Eslint under the hood, and Eslint now complains that this line does not end with a semicolon. (And yes, I know about ASI, which is one of the more stupid parts of JS, and OTOH the whole semicolon stuff which percolated from C to JS, Java etc. is absurd, especially compared to the elegant beauty of Lisp’s parens – but we do end lines with semicolons in our team, that’s just what we do, shut up.)

So, to satisfy my OCD (and I’m not mocking people with actual OCD here, mind you – I am fairly confident that I’m a relatively mild case myself, which is annoying sometimes), I need to go to the EOL, put the fu…nny semicolon there and back up to between the parens, and only then can I think about the actual argument list or whatever.

Except that I don’t really have to. This. Is. Emacs!

Look, I don’t want Emacs to put those semicolons for me automatically every time I press the opening paren, or curly brace, or a bracket. That would be way too aggressive – after all, sometimes I might want a comma after the closing delimiter, sometimes I might not need anything. But what if pressing that semicolon when the point is to the left of a closing delimiter would put that semicolon after the delimiter? The key observation here is that in JS, you never want a semicolon right before a closing delimiter. (Well, hardly ever – it is possible to think of a situation when you do. But in these exceptional cases, you can always say C-q ; and you’re good.)

It turns out that there is a very easy way I can make my life easier in this case. Here’s the idea. Let us make the semicolon key move first past any number of closing delimiters to the right of the point, insert a semicolon there, and get back to where we were.

(defun self-insert-after-closing ()
  "Move past any closing delimiters and call `self-insert-command' there."
  (interactive)
  (save-excursion
    (skip-syntax-forward ")")
    (self-insert-command 1)))

That’s how simple it is! I can now just bind the semicolon to this newly defined command and I’m good to go! The skip-syntax-forward function is quite useful, since I don’t need any regex magic here, just an Elisp primitive.

Except this is not the whole story. Now that I have this, I may also want to do the same thing to the comma (that way, you can put a function call as one of the things in an array, for instance, using my nice trick). That, however, is not as simple as it sounds. You have legitimate reasons to put a comma to the left of a closing paren in JS – and that happens when you are typing the argument list. If you have main(arg1*), where the point is where I put the asterisk, pressing the comma key should really put it there. So, I figured that I should modify my function to only perform its magic when the point is between delimiters – being to the left of a closing delimiter but to the right of something else than an opening one is not enough. Happily, the skip-syntax-... functions return the number of characters they skip (negative in the case of the backward variant), so here it is.

(defun self-insert-after-closing ()
  "Insert the character typed, after closing delimiters if necessary."
  (interactive)
  (if (zerop (save-excursion (skip-syntax-backward "(")))
      (self-insert-command 1)
    (save-excursion
      (skip-syntax-forward ")")
      (self-insert-command 1))))

This is substantially more complex (though still took me less than 5 minutes coding), but works much better. Still, it breaks in legitimate cases like a(b(*)), where the comma should be put after the first closing paren but before the second one. (Also, in such a case my function is not very useful anyway, but let’s disregard that for now.)

So, finally, here is the best version I came up with. It is still not ideal (it doesn’t care if the delimiters match, so in cases like [(*}) it will just happily put a comma/semicolon after the closing ) – but then, it is not at all obvious what it should do then), but it should cover 99% of use cases, which is certainly good enough for me.

(defun self-insert-after-closing ()
  "Move past any closing delimiters and call `self-insert-command' there."
  (interactive)
  (let ((opening-before-point-count (save-excursion (- (skip-syntax-backward "("))))
	(closing-after-point-count (save-excursion (skip-syntax-forward ")"))))
    (if (and (> opening-before-point-count 0)
	     (> closing-after-point-count 0))
	(save-excursion
	  (forward-char (min opening-before-point-count closing-after-point-count))
	  (self-insert-command 1))
      (self-insert-command 1))))

And this is pretty much it for today. (Of course, it’s not the end of the story – for instance, it should really be turned into a minor mode etc. But these are just technicalities I’ll save for another time.)

CategoryEnglish, CategoryBlog, CategoryEmacs