Zed Decoded: Rope and SumTree
zed.dev
zed.dev
I want to use it, but I don’t think I am going to be able to justify investing in new editors at this point unless there is something extremely compelling about it.
I'm at the point of using it for some new projects and continuing with neovim for my existing ones -- there's a lot of community input e.g. nvim-surround functionality was just added. I think at this point it does everything that I have in my (somewhat minimal) nvim config (and much of that is tree-sitter/LSP).
The one big feature I'm interested in is dap support so I can finally give up on gdb cli.
And truly believe they are onto something big with multiplayer coding, chat, collab.
But I’ve run into so many bugs I’ve had to stop using Zed.
The amount of time I’ve spent filing bugs, troubleshooting, documenting errors etc - is counter productive.
It makes me realize that I’m old enough now to just want things to work.
I’ll give it another go once it hits 1.0
Until then, wishing them the best and for them to make the developer world better for us all.
the editor itself has been very stable for me (daily usage) and the plugin ecosystem definitely offers so many choices that most of the time I can find stable/well-working ones for my use case.
Conveys a lovely element of gardening: new bits of code appear like shoots in the garden, often troubling if they aren't what one planted, taking time to weed giving nutritious shoots room to grow.
It’s nice to see the idea is catching on. It’s a good one.
type ShowS = String -> String
`Text` is generally preferred to `String` in Haskell, but I've happily written parsers that depend on `ShowS` to defer concatenation to linear time final output. `Text` to me always felt more C-like, and better suited to C-like applications.I've survived in math partly by attempting to catalog how others think. I sense a divide in Haskell, between people who prefer to view the compiler as a hermetically sealed abstraction, and those of us who try exploit what we think the compiler is doing. To view `ShowS` as a rope one needs to consider how the compiler handles lazy evaluation.
Rope is a data structure, not a function.
https://hackage.haskell.org/package/base-4.19.1.0/docs/GHC-S...
https://hackage.haskell.org/package/base-4.19.1.0/docs/Data-...
A niche example I tried on a whim that just worked: You can multi-select lines (say, the start of every other line, or arbitrary points in the file) and then use Vim commands to apply changes only at those locations.
I'm kind of blown away.
Also since there is no extra visual mode it is very easy to select something and then operate on it.
And Helix too. It's like a middle ground between Vim and Kakoune
[0] https://github.com/hummy123/brolib-sml
[1] https://github.com/hummy123/brolib
[2] https://github.com/hummy123/brolib-fs
The essence of a data structure is a binary tree where the internal nodes (the N2 case) contains a pointer to the left subtree, an integer containing the total length of the strings in the left subtree and a pointer to the right subtree. Then there are leaf nodes (the N0 case) which contain simply strings. (There are some other cases in the type like N1, N3 and L2, but those are solely for balancing because I built my ropes on top of 1-2 Brother Trees as described by Ralf Hinze, and those aren't essential to the rope data structure.)
When indexing (which is necessary for the insertion and deletion operations), you have a simple recursive algorithm which can be best seen in the recursive "ins" function. In the internal N2 nodes, the algorithm is to compare the index (given as an argument) with the left metadata. If the index argument is less than the left metadata, recurse to the left subtree passing the same index; otherwise, recurse to the right subtree, subtracting the index argument with the left metadata.
By the end, when you eventually reach the leaf case, the index argument is equal to the position you want to insert into in the current node. (I haven't tried to understand the maths behind this but it's how the data structure works.) At that point, all you do is insert into the leaf node's string (this is the same as inserting at an arbitrary index in any normal string) if you can without exceeding the maximum limit, or else you can insert another node.
Then you unroll the recursion. Unrolling the recursion involves updating the left subtree metadata when you reach the parent, and it also involves balancing. (I'm using 1-2 Brother Trees for balancing but ropes don't really care which balancing you use or if you use one at all.)
That's pretty much all there is to ropes. The deletion and substring algorithms just require minor modifications (the user might specify a range that includes more than one subtree, so you might need to recurse on both subtrees).
You can extend the idea behind ropes to hold more metadata too. For example, rope.sml (Standard ML) also tracks line metadata to allow indexing by line number. The changes required for this are: store an array at the leaf nodes containing indices of line breaks in the string also at this node, and at internal N2 nodes you should also store an additional integer indicating the number of lines in the left subtree.
There is an idea I haven't found too useful which is, if two leaf nodes have strings that can be joined without reaching the maximum limit, then join them. I haven't found this idea to improve performance much although it theoretically should.
I want to give a shout out to the MLton compiler for Standard ML here - the two rope implementations compiled with it handily beat the fastest ropes in Rust which is surprising. (My code performs well with F# and OCaml, but MLton takes it a level beyond that.)
open length |
{ open left length, open right length } |
{ open left length, max contained line, open right length }
The cases are obvious depending on the number of newlines in the substring (think of newlines as the commas in the tuples :) 0 newlines: open length
1 newline: { open left length, open right length }
2+ newlines: { open left length, max contained line, open right length }
Merging (summing) two maxline objects is also fairly obvious. There are 3*3 cases: contract (add) adjacent open counts, max with any contained line counts, preserve left and right open lengths.The maxline ops are (obviously) non-commutative, but are associative, so you can apply them in any grouping, left-to-right, right-to-left, and even in parallel if you feel the need.
At the root of the tree, the left and right open lengths become actual lines (beginning and end of buffer are virtual line delimiters), so to get the global maximum line length, just max the 1, 2, or 3 values in the root maxline summary.
I like to separate newlines from other substrings as leaves in the tree. So the leaf maxlines are just:
substring: length
newline: {0,0}
Then sum as given above across any number of child nodes.The Zed blog claims to have such a thing, but didn't explain it. Apologies if it's in one of the repos. I didn't check.
In a pointer-to-mutable-memory language, a rope becomes a lot of recursive pointer-chasing inside a tree structure, typically referenced from the root down.
In a functional language, a rope may be built as a tree (or several subtree edits), but is processed from local cursors that have a bottom-up view from a concrete leaf position in the buffer.
A rope in a purely functional language is better described as a Finger Tree (Hinze, Paterson) [1] or Zipper (Huet) [2]:
[1] https://www.staff.city.ac.uk/~ross/papers/FingerTree.html
[2] https://www.st.cs.uni-saarland.de/edu/seminare/2005/advanced...
If you really love strings, you might now
be thinking "better than a string? easy:
multiple strings." And you wouldn't be
that far off! Some editors do represent
text as an array-of-lines with each line
being a string. VS Code's Monaco editor
worked that way for quite a while, but an
array of strings can still be plagued by
the same problems as a single string.
Excessive memory consumption and
performance issues made the VS Code team
look for something better.Not saying that array of lines is more performant than ropes, but just adding a datapoint here.