|
| 1 | += How to Make a 💡? |
| 2 | +@matklad |
| 3 | +:sectanchors: |
| 4 | +:experimental: |
| 5 | +:page-layout: post |
| 6 | + |
| 7 | +rust-analyzer is a new "IDE backend" for the https://www.rust-lang.org/[Rust] programming language. |
| 8 | +Support rust-analyzer on https://opencollective.com/rust-analyzer/[Open Collective] or https://github.com/sponsors/rust-analyzer[GitHub Sponsors]. |
| 9 | + |
| 10 | +My favorite IDE feature is a light bulb -- a little 💡 icon that appears next to a cursor which you can click on to apply a local refactoring. |
| 11 | +In the first part of this post, I'll talk about why this little bulb is so dear to my heart, and in the second part I'll go into some implementation tips and tricks. |
| 12 | +First part should be interesting for everyone, while the second part is targeting folks implementing their own IDEs / language serves. |
| 13 | + |
| 14 | +== The Mighty 💡 |
| 15 | + |
| 16 | +https://martinfowler.com/bliki/PostIntelliJ.html[Post-IntelliJ] IDEs, with their full access to syntax and semantics of the program, can provide almost an infinite amount of smart features. |
| 17 | +The biggest problem is not implementing the features, the biggest problem is teaching the users that a certain feature exists. |
| 18 | + |
| 19 | +One possible UI here is a fuzzy-searchable command palette: |
| 20 | + |
| 21 | +image::/assets/blog/how-to-make-a-light-bulb/emacs-helm.png[] |
| 22 | + |
| 23 | +This helps if the user (a) knows that some command might exist, and (b) can guess its name. |
| 24 | +Which is to say: not that often. |
| 25 | + |
| 26 | +Contrast it with the light bulb UI: |
| 27 | + |
| 28 | +First, by noticing a 💡 you see that _some_ feature is available in this particular context: |
| 29 | + |
| 30 | +image::/assets/blog/how-to-make-a-light-bulb/bulb1.png[] |
| 31 | + |
| 32 | +Then, by clicking the 💡 (kbd:[ctrl+.] in VS Code / kbd:[Alt+Enter] in IntelliJ) you can see a _short_ list of actions applicable in the current context: |
| 33 | + |
| 34 | +image::/assets/blog/how-to-make-a-light-bulb/bulb2.png[] |
| 35 | + |
| 36 | +This is a rare case where UX is both: |
| 37 | + |
| 38 | +* Discoverable, which makes novices happy. |
| 39 | +* Efficient, to make expert users delighted as well. |
| 40 | + |
| 41 | +I am somewhat surprised that older editors, like Emacs or Vim, still don't have the 💡 concept built-in. |
| 42 | +I don't know which editor/IDE pioneered the light bulb UX; if you know, please let me know the comments! |
| 43 | + |
| 44 | +== How to Implement a 💡? |
| 45 | + |
| 46 | +If we squint hard enough, an IDE/LSP server works a bit like a web server. |
| 47 | +It accepts requests like "`what is the definition of symbol on line 23?`", processes them according to the language semantics and responds back. |
| 48 | +Some requests also modify the data model itself ("here's the new next of foo.rs file: '...'"). |
| 49 | +Generally, the state of the world might change between any two requests. |
| 50 | + |
| 51 | +**** |
| 52 | +In single-process IDEs (IntelliJ) requests like code completion generally modify the data directly, as the IDE itself is the source of truth. |
| 53 | +
|
| 54 | +In client-server architecture (LSP), the server usually responds with a diff and receives an updated state in a separate request -- client holds the true state. |
| 55 | +**** |
| 56 | + |
| 57 | +This is relevant for 💡 feature, as it usually needs two requests. |
| 58 | +The first request takes the current position of the cursor and returns the list of available assists. |
| 59 | +If the list is not empty, the 💡 icon is shown in the editor. |
| 60 | + |
| 61 | +The second request is made when/if a user clicks a specific assist; this request calculates the corresponding diff. |
| 62 | + |
| 63 | +Both request are initiated by user's actions, and arbitrary events might happen between the two. |
| 64 | +Hence, assists can't assume that the state of the world is intact between `list` and `apply` actions. |
| 65 | + |
| 66 | +This leads to the following interface for assists (lightly adapted |
| 67 | +https://github.com/JetBrains/intellij-community/blob/680dbb522465d3fd3b599c2c582a7dec9c5ad02b/platform/analysis-api/src/com/intellij/codeInsight/intention/IntentionAction.java[`InteitionAction`] |
| 68 | +from IntelliJ |
| 69 | +) |
| 70 | + |
| 71 | +[source,kotlin] |
| 72 | +---- |
| 73 | +interface IntentionAction { |
| 74 | + val name: String |
| 75 | + fun isAvailable(position: CursorPosition): Boolean |
| 76 | + fun invoke(position: CursorPosition): Diff |
| 77 | +} |
| 78 | +---- |
| 79 | + |
| 80 | +That is, to implement a new assist, you provide a class implementing `IntentionAction` interface. |
| 81 | +The IDE platform then uses `isAvailable` and `getName` to populate the 💡 menu, and calls `invoke` to apply the assist if the user asks for it. |
| 82 | + |
| 83 | +This interface has exactly the right shape for the IDE platform, but is awkward to implement. |
| 84 | + |
| 85 | +**** |
| 86 | +This is a specific instance of a more general phenomenon. |
| 87 | +Each abstraction has https://en.wikipedia.org/wiki/The_Disk[two faces] -- one for the implementer, one for the user. |
| 88 | +Two sides often have slightly different requirements, but tend to get implemented in a single language construct by default. |
| 89 | +**** |
| 90 | + |
| 91 | +Almost always, the code at the start of `isAvailable` and `invoke` would be similar. |
| 92 | +Here's a bigger example from PyCharm: |
| 93 | +https://github.com/JetBrains/intellij-community/blob/680dbb522465d3fd3b599c2c582a7dec9c5ad02b/python/python-psi-impl/src/com/jetbrains/python/codeInsight/intentions/PySplitIfIntention.java#L34-L48[`isAvailable`] |
| 94 | +and |
| 95 | +https://github.com/JetBrains/intellij-community/blob/680dbb522465d3fd3b599c2c582a7dec9c5ad02b/python/python-psi-impl/src/com/jetbrains/python/codeInsight/intentions/PySplitIfIntention.java#L72-L82[`invoke`]. |
| 96 | + |
| 97 | +To reduce this duplication in Intellij Rust, I introduced a convenience base class https://github.com/intellij-rust/intellij-rust/blob/3527d29f7c42412e33125dabb2f86acf3a46bc86/src/main/kotlin/org/rust/ide/intentions/RsElementBaseIntentionAction.kt[`RsElementBaseIntentionAction`]: |
| 98 | + |
| 99 | +[source,kotlin] |
| 100 | +---- |
| 101 | +class RsIntentionAction<Ctx>: IntentionAction { |
| 102 | + fun getContext(position: CursorPosition): Ctx? |
| 103 | + fun invoke(position: CursorPosition, ctx: Ctx): Diff |
| 104 | +
|
| 105 | + override fun isAvailable(position: CursorPosition) = |
| 106 | + getContext(position) != null |
| 107 | +
|
| 108 | + override fun invoke(position: CursorPosition) = |
| 109 | + invoke(position, getContext(position)!!) |
| 110 | +} |
| 111 | +---- |
| 112 | + |
| 113 | +The duplication is removed in a rather brute-force way -- common code between `isAvailable` and `invoke` is reified into (assist-specific) `Ctx` data structure. |
| 114 | +This gets the job done, but defining a `Context` type (which is just a bag of stuff) is tedious, as seen in, for example, https://github.com/intellij-rust/intellij-rust/blob/3527d29f7c42412e33125dabb2f86acf3a46bc86/src/main/kotlin/org/rust/ide/intentions/InvertIfIntention.kt#L16-L21[InvertIfIntention.kt]. |
| 115 | + |
| 116 | +rust-analyzer uses what I feel is a slightly better pattern. |
| 117 | +Recall our original analogy between an IDE and a web server. |
| 118 | +If we stretch it even further, we may say that assists are similar to an HTML form. |
| 119 | +The `list` operation is analogous to the `GET` part of working with forms, and `apply` looks like a `POST`. |
| 120 | +In an HTTP server, the state of the world also changes between `GET /my-form` and `POST /my-form`, so an HTTP server also queries the database twice. |
| 121 | + |
| 122 | +Django web framework has a nice pattern to implement this -- function based views. |
| 123 | + |
| 124 | +[source,python] |
| 125 | +---- |
| 126 | +
|
| 127 | +def my_form(request): |
| 128 | + ctx = fetch_stuff_from_postgress() |
| 129 | + if request.method == 'POST': |
| 130 | + # apply changes ... |
| 131 | + else: |
| 132 | + # render template ... |
| 133 | +---- |
| 134 | + |
| 135 | +A single function handles both `GET` and `POST`. |
| 136 | +Common part is handled once, differences are handled in two branches of the `if`, a runtime parameter selects the branch of `if`. |
| 137 | + |
| 138 | +**** |
| 139 | +See https://spookylukey.github.io/django-views-the-right-way/[Django Views — The Right Way] for the most recent discussion why function based views are preferable to class based views. |
| 140 | +**** |
| 141 | + |
| 142 | +This pattern, translated from a Python web framework to a Rust IDE, looks like this: |
| 143 | + |
| 144 | +[source,rust] |
| 145 | +---- |
| 146 | +enum MaybeDiff { |
| 147 | + Delayed, |
| 148 | + Diff(Diff), |
| 149 | +} |
| 150 | +
|
| 151 | +
|
| 152 | +fn assist(position: CursorPosition, delay: bool) |
| 153 | + -> Option<MaybeDiff> |
| 154 | +{ |
| 155 | + let ctx = compute_common_context(position)?; |
| 156 | + if delay { |
| 157 | + return Some(MaybeDiff::Delayed); |
| 158 | + } |
| 159 | +
|
| 160 | + let diff = compute_diff(position, ctx); |
| 161 | + Some(MaybeDiff::Diff(diff)) |
| 162 | +} |
| 163 | +---- |
| 164 | + |
| 165 | +The `Context` type got dissolved into a set of local variables. |
| 166 | +Or, equivalently, `Context` is a reification of control flow -- it is a set of local variables which are live before the `if`. |
| 167 | +One might even want to implement this pattern with coroutines/generators/async, but there's no real need to, as there's only one fixed suspension point. |
| 168 | + |
| 169 | +For a non-simplified example, take a look at https://github.com/rust-analyzer/rust-analyzer/blob/550709175071a865a7e5101a910eee9e0f8761a2/crates/assists/src/handlers/invert_if.rs#L31-L63[invert_if.rs]. |
0 commit comments