Skip to content

Commit 34c9364

Browse files
authored
Merge pull request #161 from matklad/ides-vs-macros
Blog Post: IDEs and Macros
2 parents 609a70d + 029f891 commit 34c9364

File tree

1 file changed

+247
-0
lines changed

1 file changed

+247
-0
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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

Comments
 (0)