Embrace the Chaos
Over the past 21 months I’ve written a code editor from the ground up, including: a fast (~120fps) canvas-based editor component; multi-language syntax highlighting with Tree-sitter; snippets; a file browser; find-and-replace with regex and JavaScript expression support; and two completely new features: AST mode and CodePatterns.
In this post I will describe the development approach I’ve followed, which has allowed me to go from zero to a usable editor in 4 months; to daily-drivability in 7 months; and continues to make working on it easy and rewarding as it matures.
Embrace chaos
* A brief aside on algorithms: I find that I start most of my algorithms with
while (true)
and it usually sticks. It’s like firing up a little spinner that’s ready to do some kind of iterative process, but without the cognitive load of recursion or the friction of having to come up with a boundary condition before writing the body.
When you’ve got a while (true)
* loop that searches a tree, it’s gonna throw you an infinite loop every now and then. Fortunately, the more times this happens the better you’ll get at lightweight debugging strategies, and the more familiar you’ll become with the algorithm and the data structures. Soon it won’t seem like a big deal to:
- quickly add
if (iterations === 1000) { debugger; }
to the top of the loop; - step into the code;
- walk through a few iterations to get a feel for the repeated pattern that the loop is stuck in;
- figure out how to solve it.
You could meticulously unit-test each step of the algorithm and validate each assumption as you go, but between changing dependencies, evolving code and requirements, and the fact that you’re almost certainly making assumptions you’re not even aware of (recent example), I’ve found it better to just embrace the chaos and fix the bugs as they come up.
Unit test haphazardly
For nontrivial algorithms I like to start with unit tests and then use them as necessary for debugging later on (bringing them up to date if required). Don’t waste time keeping unit tests constantly up-to-date if the code is working. Once the code is both correct and well-factored, feel free to disable or remove the unit tests so they don’t clutter your test runs with errors after subsequent refactorings.
Update 2 Jan 2024: I hit a weird bug recently that made me wonder whether more unit testing, and a more rigorous approach, wouldn’t have been such a bad idea. While editing a markdown blog post, I cut a list item and pasted it into another list further up the document. This sent the renderer into an infinite loop.
I eventually narrowed the bug down to edits that added one or more lines above a list, but only if the list had two or more entries with the exact same number of characters!
This is the kind of bug you find after two years of daily use—or by fuzzing. It would not have been avoided by more thorough unit testing, for the reason I mention above: I had no idea I was making the underlying false assumption. If I had known, I would have known to write a test case, but I would also have known to write the code correctly.
The assumption in question turned out to be not realising that an array item could, under very specific circumstances, be “used” twice. Intuitively, the item was no longer a candidate for re-use, and because of a general sense that it would not be iterated over again (even though the loop it existed in could indeed be run multiple times within the relevant timespan)—and the fact that it appeared to work—I concluded that the logic was correct.
This was in the code that handled re-using a nested scope (e.g. HTML in PHP or JavaScript in HTML) if it looked like it had been either moved by an edit within the parent scope, or an edit had been made entirely within it. In these cases, there is no need to create a new scope; the existing scope can be edited, which is much more efficient in Tree-sitter.
The error was that after editing an existing scope, it was not removed from the array of candidate “existing” scopes for re-use by other “new” scopes further down the document. So after adding a line above a list, the first new list item would find an existing scope that had been moved by the edit—the first existing scope. This scope was then edited, moving it down one line. Then, when the second new list item looked for existing scopes, it found this edited scope which matched its edited position. So the first existing scope was re-used twice, and the second one was ignored.
So the answer here was, I think, fuzzing, not more unit testing.
Use it early
A unique property of writing a code editor is that after a very short bootstrapping period, the thing you’re writing will also be the main tool you’re using to write it. This means two things:
you quickly get to a point where you’ve written your own code editor and are using it to write itself, which is a very satisfying achievement; and
you always know exactly what to work on (see below).
You always know what to work on
If you’re using the code editor to write itself, the most pressing bugs and missing features will make themselves apparent to you for free. Once you’ve manually indented the fifth line in a row after expecting it to auto-indent, your brain will tell you exactly what it’s tired of! Drop everything and work on that. It’s hard to overstate the value of (almost) never having to think about what you should be working on next.
Refactor when it seems like a good idea
Refactoring can be done early or late; it doesn’t really matter. I’d been using Edita for over a year when I finally got round to making the basic Selection
and Cursor
objects full classes; prior to that they’d been namespaced utility functions and anonymous objects. Similarly with Tree-sitter-related classes and utils – it was a long time before this:
treeSitterPointToCursor(nodeUtils.endPosition(nodeA)).isBefore(treeSitterPointToCursor(nodeUtils.startPosition(nodeB)))
became this:
nodeA.end.isBefore(nodeB.start)
It’s a great feeling when the code clicks into place in a new, leaner shape – but don’t let that distract you from working on what’s important.
Refactor fearlessly (even without unit tests)
Don’t worry about breaking things. If the new structuring you’ve thought of is better, get stuck in and change the code. You will introduce bugs this way, but because you’re using the editor every day to write itself, you’ll find them quickly (in fact, almost by definition, you’ll tend to find them more quickly the more important they are) and your policy of always working on whatever needs fixing right now will make sure they’re fixed promptly.
An added bonus is that breaking things now and then will randomly show you parts of the code you haven’t seen in a while, so you can give them some attention if they need it.
Keep hacking
Sticking to this approach has served me well and resulted in an editor that frustrates me much less frequently than my old one did, even though it was built and maintained by a large team and on top of an existing open-source core.
Randomly deleting unit tests may seem like insanity to some of you, and in certain contexts it probably is – but writing your own editor is a particular endeavour, and if you embark on it I encourage you to do whatever feels right to you, refactor as you please, and embrace the chaos. Happy hacking!