|
| 1 | += IDEs and Macros |
| 2 | +@matklad |
| 3 | +:sectanchors: |
| 4 | +:page-layout: post |
| 5 | + |
| 6 | +In article, we'll discuss challenges that language servers face when supporting macros. |
| 7 | +This is interesting, because rust-analyzer, macros are the hardest nut to crack. |
| 8 | + |
| 9 | +While we use Rust as an example, the primary motivation here is to inform future language design. |
| 10 | +As this is a case study rather than a thorough analysis, conclusions should be taken with a grain of salt. |
| 11 | +In particular, I know that Scala 3 has a revamped macro system which _might_ contain all the answers, but I haven't looked at it deeply. |
| 12 | +Finally, note that the text is unfairly biased _against_ macros: |
| 13 | + |
| 14 | +* I write IDEs, so macros for me are a problem to solve, rather than a tool to use. |
| 15 | +* My personal code style tends towards preferring textual verbosity over using advanced language features, so I don't use macros that often. |
| 16 | +
|
| 17 | +== Meta Challenges |
| 18 | + |
| 19 | +The most important contributing factor to complexity is non-technical. |
| 20 | +Macros are _disproportionally_ hard to support in an IDE. |
| 21 | +That is, if adding macros to a batch compiler takes `X` amount of work, making them play nicely with all IDE features takes `X²`. |
| 22 | +This crates a pull for languages to naturally evolve more complex macro systems than can be reasonably supported by dev tooling. |
| 23 | +The specific issues are as follows: |
| 24 | + |
| 25 | +== Mapping Back |
| 26 | + |
| 27 | +_First_, macros can compromise the end-user experience, because some IDE features are just not well-defined in the presence of macros. |
| 28 | +Consider this code, for example: |
| 29 | + |
| 30 | +[source,rust] |
| 31 | +---- |
| 32 | +struct S { x: u32, y: u32 } |
| 33 | +
|
| 34 | +fn make_S() -> S { |
| 35 | + S { x: 92 } 💡 |
| 36 | +} |
| 37 | +---- |
| 38 | + |
| 39 | +Here, a reasonable IDE feature (known as intention, code action, assist or just 💡) is to suggesting adding the rest of the fields to the struct literal: |
| 40 | + |
| 41 | +[source,rust] |
| 42 | +---- |
| 43 | +struct S { x: u32, y: u32 } |
| 44 | +
|
| 45 | +fn make_S() -> S { |
| 46 | + S { x: 92, y: todo!() } |
| 47 | +} |
| 48 | +---- |
| 49 | + |
| 50 | +Now, let's add a simple compile-time reflection macro: |
| 51 | + |
| 52 | +[source,rust] |
| 53 | +---- |
| 54 | +struct S { x: u32, y: u32 } |
| 55 | +
|
| 56 | +reflect![ |
| 57 | + { |
| 58 | + { 29 :x } S 😂 |
| 59 | + } S <- ()S_ekam nf |
| 60 | +]; |
| 61 | +---- |
| 62 | + |
| 63 | +What the macro does here is just mirroring every token. |
| 64 | +IDE has no troubles expanding this macro. |
| 65 | +It also understands that, in the expansion, the `y` field is missing, and that `y: todo!()` can be added to the _expansion_ as a fix. |
| 66 | +What the IDE can't do though, is to figure out what should be changed in the code that the user wrote to achieve that effect. |
| 67 | +Another interesting case to think about is what if the macro just encrypts all identifiers? |
| 68 | + |
| 69 | +This is where "`__disproportionally__ hard`" bit lies. |
| 70 | +In a batch compiler, code generally moves only forward through compilation phases. |
| 71 | +The single exception is error reporting (which should say which _source_ code is erroneous), but that is solved adequately by just tracking source positions in intermediate representations. |
| 72 | +An IDE, in contrast, wants to modify the source code, and to do that precisely just knowing positions is not enough. |
| 73 | + |
| 74 | +What makes the problem especially hard in Rust is that, for the user, it might not be obvious which IDE features are expected to work. |
| 75 | +Let's look at a variation of the above example: |
| 76 | + |
| 77 | +[source,rust] |
| 78 | +---- |
| 79 | +#[tokio::main] |
| 80 | +async fn main() { |
| 81 | + S { x: 92 }; 💡 |
| 82 | +} |
| 83 | +---- |
| 84 | + |
| 85 | +What a user sees here is just a usual Rust function with some annotation attached. |
| 86 | +Clearly, everything should just work, right? |
| 87 | +But from an IDE point of view, this example isn't that different from the `reflect!` one. |
| 88 | +`tokio::main` is just an opaque code which takes the tokens of the source function as an input, and produces some tokens as an output, which then replace the original function. |
| 89 | +It just _happens_ that the semantics of the original code is mostly preserved. |
| 90 | +Again, `tokio::main` _could_ have encrypted every identifier!. |
| 91 | + |
| 92 | +So, to make thing appear to work, an IDE necessary involves heuristics in such cases. |
| 93 | +Some possible options are: |
| 94 | + |
| 95 | +* Just completely ignore the macro. |
| 96 | + This make boring things like completion mostly work, but leads to semantic errors elsewhere. |
| 97 | +* Expand the macro, apply IDE features to expansion, and try heuristically lift them to the original source code |
| 98 | + (this is the bit where "`and now we just guess the private key used to encrypt an identifier`" conceptually lives). |
| 99 | + This is the pedantically correct approach, but it breaks most IDE features in minor and major ways. |
| 100 | + What's worse, the breakage is unexplainable to users: "`I just added an annotation to the function, why I don't get any completions?`" |
| 101 | +* In the semantic model, maintain both precisely analyzed expanded code, as well as heuristically analyzed source code. |
| 102 | + When writing IDE features, try to intelligently use precise analysis from the expansion to augment knowledge about the source. |
| 103 | + This still doesn't solve all the problems, but solves most of them good enough such that the users now are completely befuddled by those rare cases where heuristics break down. |
| 104 | + |
| 105 | +.First Lesson |
| 106 | +[NOTE] |
| 107 | +==== |
| 108 | +Design meta programming facilities to be "`append only`". |
| 109 | +Macros should not change the meaning of existing code. |
| 110 | +
|
| 111 | +Avoid situations where what looks like normal syntax is instead an arbitrary language interpreted by a macro in a custom way. |
| 112 | +==== |
| 113 | + |
| 114 | +== Parallel Name Resolution |
| 115 | + |
| 116 | +_The second_ challenge is performance and phasing. |
| 117 | +Batch compilers typically compile all the code, so a natural solution of just expanding all the macros works. |
| 118 | +Or rather, there isn't a problem at all here, you just write the simplest code to do the expansion and things just work. |
| 119 | +Situation for an IDE is quite different -- the main reason why IDE is capable of working with keystroke latency is that it cheats. |
| 120 | +It just doesn't look at the majority of the code during code editing, and analyses the absolute minimum to provide a completion widget. |
| 121 | +To be able to do so, an IDE needs help from the language to understand which parts of code can be safely ignored. |
| 122 | + |
| 123 | +Read https://rust-analyzer.github.io/blog/2020/07/20/three-architectures-for-responsive-ide.html[this other article] to understand specific tricks IDE can employ here. |
| 124 | +The most powerful idea there is that generally IDE needs to know only about top-level names, and it doesn't need to look inside, e.g, function bodies most of the time. |
| 125 | +Ideally, an IDE processes all files in parallel, noting, for each file, which top-level names it contributes. |
| 126 | + |
| 127 | +The problem with macros, of course, is that they can contribute new top-level names. |
| 128 | +What's worse, to understand _which_ macro is invoked, an IDE needs to resolve its name, which depends on the set of top-level names already available. |
| 129 | + |
| 130 | +Here's a rather convoluted example which shows that in Rust name resolution and macro expansion are interdependent: |
| 131 | + |
| 132 | +.main.rs |
| 133 | +[source,rust] |
| 134 | +---- |
| 135 | +mod foo; |
| 136 | +foo::declare_mod!(bar, "foo.rs"); |
| 137 | +---- |
| 138 | + |
| 139 | +.foo.rs |
| 140 | +[source,rust] |
| 141 | +---- |
| 142 | +pub struct S; |
| 143 | +use super::bar::S as S2; |
| 144 | +
|
| 145 | +macro_rules! _declare_mod { |
| 146 | + ($name:ident, $path:literal) => { |
| 147 | + #[path = $path] |
| 148 | + pub mod $name; |
| 149 | + } |
| 150 | +} |
| 151 | +pub(crate) use _declare_mod as declare_mod; |
| 152 | +---- |
| 153 | + |
| 154 | +Semantics like this is what prevents rust-analyzer to just process every file in isolation. |
| 155 | +Instead, there's a hard-to-parallelize and hard to make incremental bit in rust-analyzer, where we just accept high implementation complexity and poor runtime performance. |
| 156 | + |
| 157 | +There is an alternative -- design meta programming such that it can work "`file at a time`", and can be plugged into an embarrassingly parallel indexing phase. |
| 158 | +This is the design that Sorbet, a (very) fast type checker for Ruby chooses: https://youtu.be/Gdx6by6tcvw?t=804. |
| 159 | +I _really_ like the motivation there. |
| 160 | +It is a given that people would love to extend the language in some way. |
| 161 | +It is also given that extensions wouldn't be as carefully optimized as the core compiler. |
| 162 | +So let's make sure that the overall thing is still crazy fast, even if a particular extension is slow, by just removing extensions from the hot path. |
| 163 | +(Compare this with VS Code architecture with out-of-process extensions, which just _can't_ block the editor's UI). |
| 164 | + |
| 165 | +To flesh out this design bit: |
| 166 | + |
| 167 | +* All macros used in a compilation unit must be know up-front. |
| 168 | + In particular, it's not possible to define a macro in one file of CU and use it in another. |
| 169 | +* Macros follow simplified name resolution rules, which are intentionally different from the usual ones to allow recognizing and expanding macros _before_ name resolution. |
| 170 | + For example, macro invocations could have a unique syntax, like `name!`, where `name` identifies a macro definition in the flat namespace of know-up-front macros. |
| 171 | +* Macros don't get to access anything outside of the file with macro invocation. |
| 172 | + They _can_ simulate name resolution for identifiers within the file, but can't reach across files. |
| 173 | + |
| 174 | +Here, limiting macros to local-only information is a conscious design choice. |
| 175 | +By limiting the power available to macros, we gain the properties we can use to make the tooling better. |
| 176 | +For example, a macro can't know a type of the variable, but because it can't do that, we know we can re-use macro expansion results when unrelated files change. |
| 177 | + |
| 178 | +An interesting hack to regain the full power of type-inspecting macros is to move the problem from the language to the tooling. |
| 179 | +It is possible to run a code generation step before the build, which can use compiler as a library to do a global semantic analysis of the code written by the user. |
| 180 | +Based on the analysis results, the tool can write some generated code, which would then be processed by IDEs as if it was written by a human. |
| 181 | + |
| 182 | +.Second Lesson |
| 183 | +[NOTE] |
| 184 | +==== |
| 185 | +Pay close attention to the interactions between name resolution and macro expansions. |
| 186 | +Besides well-known hygiene issues, another problem to look out for is accidentally turning name resolution from an embarrassingly parallel problem into an essentially sequential one. |
| 187 | +==== |
| 188 | + |
| 189 | +== Controllable Execution |
| 190 | + |
| 191 | +The _third_ problem is that, if macros are sufficiently powerful, the can do sufficiently bad things. |
| 192 | +To give a simple example, here's a macro which expands to an infinite number of "`no`": |
| 193 | + |
| 194 | +[source,rust] |
| 195 | +---- |
| 196 | +macro_rules! m { |
| 197 | + ($($tt:tt)*) => { m!($($tt)* $($tt)*); } |
| 198 | +} |
| 199 | +m!(no); |
| 200 | +---- |
| 201 | + |
| 202 | +The behavior of command-line compiler here is to just die with out-of-memory error, and that's an OK behavior for this context. |
| 203 | +Of course it's better when the compiler gives a nice error message, but if it misbehaves and panics or loops infinitely on erroneous code, that is also OK -- the user can just `^C` the process. |
| 204 | + |
| 205 | +For a long-running IDE process though, looping or eating all the memory is not an option -- all resources need to be strictly limited. |
| 206 | +This is especially important given that an IDE looks at incomplete and erroneous code most of the time, so it hits far more weird edge cases than a batch compiler. |
| 207 | + |
| 208 | +Rust procedural macros are all-powerful, so rust-analyzer and IntelliJ Rust have to implement extra tricks to contain them. |
| 209 | +While `rustc` just loads proc-macro shared library into the process, IDEs load macros into a dedicated external process which can be killed without bringing the whole IDE down. |
| 210 | +Adding IPC to an otherwise purely-functional compiler code is technically challenging. |
| 211 | + |
| 212 | +A related problem is determinism. |
| 213 | +rust-analyzer assumes that all computations are deterministic, and it uses this fact to smartly forget about subsets of derived data, to save memory. |
| 214 | +For example, once a file is analyzed and a set of declarations is extracted out of it, rust-analyzer destroys its syntax tree. |
| 215 | +If the user than goes to a definition, rust-analyzer re-parses the file from source to compute precise ranges, highlights, etc. |
| 216 | +At this point, it is important the tree is exactly the same. |
| 217 | +If that's not the case, rust-analyzer might panic because various indices from previously extracted declarations get out of sync. |
| 218 | +But in the presence of non-deterministic procedural macros, rust-analyzer actually _can_ get a different syntax tree. |
| 219 | +So we have to specifically disable the logic for forgetting syntax trees for macros. |
| 220 | + |
| 221 | +.Third Lessons |
| 222 | +[NOTE] |
| 223 | +==== |
| 224 | +Make sure that macros are deterministic, and can be easily limited in the amount of resources they consume. |
| 225 | +For a batch compiler, it's OK to go with optimistic best-effort guarantees: "`we assume that macros are deterministic and can crash otherwise`". |
| 226 | +IDEs have stricter availability requirements, so they have to be pessimistic: "`we cannot crash, so we assume that any macro is potentially non-deterministic`". |
| 227 | +==== |
| 228 | + |
| 229 | +Curiously, similar to the previous point, moving meta programming to a code generation build system step sidesteps the problem, as you again can optimistically assume determinism. |
| 230 | + |
| 231 | +== Recap |
| 232 | + |
| 233 | +When it comes to meta programming, IDEs are harder than the batch compilers. |
| 234 | +To paraphrase Kernighan, if you design meta programming in your compiler as cleverly as possible, you are not smart enough to write an IDE for it. |
| 235 | + |
| 236 | +Some specific hard macro bits: |
| 237 | + |
| 238 | +* In a compiler, code flows forward through compilation pipeline. |
| 239 | + IDE features generally flow _back_, from desugared code into the original source. |
| 240 | + Macros can easily make for an irreversible transformation. |
| 241 | + |
| 242 | +* IDE is fast because it knows what to _not_ look at. |
| 243 | + Macros can hide what is there, and increase the minimum amount of work necessary to understand an isolated bit of code. |
| 244 | + |
| 245 | +* User-written macros can crash. |
| 246 | + IDE can not crash. |
| 247 | + Running macros from an IDE is therefore fun :-) |
0 commit comments