Just a curiosity: Language Server Protocol and lisps? #76
Replies: 2 comments 1 reply
-
Hey, good question - so let me preface this whole thing with a few caveats: I would not consider myself an expert in LSPs (I have a work in progress one for Steel, but that is it) or in speaking to the end to end design of all programming languages ever, so everything I speak to is more or less related to Steel and my knowledge of relatively standard scheme implementations (i.e. not intentionally clever to enable LSP support or design with LSP support in mind). What is interesting about LSPs and schemes like Steel is that the REPL is morally equivalent in many ways to what you want the interaction to be with an LSP, with a fairly hefty difference - you probably don't want the LSP actually executing any code, but you want everything except that - i.e. an environment that you can query and interact with, asking questions about the code, checking if things are free identifiers, keeping span information around to have decent error reporting, etc. So, conceptually, having a working REPL is close to what you want out of an LSP. In practice though there is a large difference of course, so we can go into that. There are things that can be statically recognized, and things that are deferred to runtime. In a language like Rust or Go, almost all behavior that the language server will report on is statically recognizable. A top level function defined in a Rust module and referenced from another will always refer to that definition, so we can easily do jump to definition style things. But scheme generally does not carry that same guarantee - no where in the semantics does it say that a value cannot be redefined later. Similarly in python, there is nothing stopping me from redefining values at runtime or even fussing with global values, module imports, etc, at runtime - so the jump to definitions and kinds of things that we can statically report become invalidated quickly in the face of certain behaviors. Not only that - but schemes generally have a great deal of behavior statically hidden behind macros, which is also a place where Rust fails to report things as well. You could write a macro that defines a function, but then also immediately changes what it is defined as, export the function and call it from another file. For the user of that symbol, we now have suspect behavior. Typically, modern Python projects have lots of type annotations and use lots of linters to assist with the IDE tooling - these kinds of static definitions generally (try to) limit the kind of funny business that you can do at runtime (you could ignore the type annotations, but the type checker tooling assumes you're not doing that), which then the IDE can depend on to give you more helpful assistance. Lots of things in dynamic languages that we might think are static are by default, not static. Naively, all type checking happens at runtime - every operation checks that the types match for the given function you're executing. More intelligently, there are optimizations that can happen where we statically know given the knowledge of whole program that we can elide type checking and say that we 100% know things will be a certain way. But of course, these tend to be tenuous at best - there are many behaviors that can invalidate certain guarantees that we depend on. I did not design Steel around an LSP experience in mind, however by virtue of being a dynamic language with a REPL, it lends itself relatively nicely to the structure you might want for an LSP, since you just assume that at runtime, things can change, and you bake that in to how you structure the runtime. For example, globals are just slots in a vector - those globals can be |
Beta Was this translation helpful? Give feedback.
-
Thanks you for the clear answer. I find it mind blowing to consider what can be examined at "development time". In TypeScript, types are tools mainly for the compiler to provide hints to developers. If I'm getting this right, contracts in languages like Steel provide checks at runtime, but can they also be partially understood during development? As I delved deeper into this topic, I didn't mind going down to a rabbit hole. I explored topics like linters vs. type checkers, parsers, indexing, and partial module loading. In my quest for knowledge, I came across this blog: https://go.dev/blog/gopls-scalability, and it made me realize the complex design challenges that lsp can bring about! |
Beta Was this translation helpful? Give feedback.
-
Hi @mattwparas,
I couldn't resist joining in the discussion section here to share my useless thoughts -.-". Sorry for my question, but I'm really curious (and admittedly a bit ignorant) about how a tool like rust-analyzer works.
In my experience with Helix, I've found tools like rust-analyzer, typescript-language-server, and gopls to be incredibly helpful. I especially love features like auto-import, auto-completions, jumping to import source code, navigating definitions, finding references, accessing documentation, jumping to tests, renaming functions, and so on. I believe robust LSP support is one of Helix's out of the box killer features.
Now, I'm pondering:
When I work with languages like Python or JavaScript, I often notice that IDE support becomes less consistent. The inability to recognize interfaces or APIs can be frustrating. Module imports aren't always handled automatically, and renaming across files and scopes can be a challenge. I think this is often due to the nature of weakly typed languages.
With that in mind, I'm also curious about how the dynamic nature of Lisps, like Steel, influences the language itself and how that might affect the user-friendliness of the language server.
Hope my question make sense!
Beta Was this translation helpful? Give feedback.
All reactions