Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Steel as an optional plugin system #8675

Draft
wants to merge 156 commits into
base: master
Choose a base branch
from

Conversation

mattwparas
Copy link

Notes:

  • I still need to rebase up with the latest master changes, however doing so causes some headache with the lock file, so I'll do it after some initial feedback. Also, this depends on the event system in Add an event system #8021.
  • The large diff size is a combination of lock file changes + the dependency on the event system PR. The diff has ended up quite large with all of the other stuff
  • I'm currently pointing to the master branch of steel as a dependency. This will point to a stable release on crates once I cut a release.

Opening this just to track progress on the effort and gather some feedback. There is still work to be done but I would like to gather some opinions on the direction before I continue more.

You can see my currently functioning helix config here and there are instructions listed in the STEEL.md file. The main repo for steel lives here, however much documentation is in works and will be added soon.

The bulk of the implementation lies in the engine.rs and scheme.rs files.

Design

Given prior conversation about developing a custom language implementation, I attempted to make the integration with Steel as agnostic of the engine as possible to keep that door open.

The interface I ended up with (which is subject to change and would love feedback on) is the following:

pub trait PluginSystem {
    /// If any initialization needs to happen prior to the initialization script being run,
    /// this is done here. This is run before the context is available.
    fn initialize(&self) {}

    fn engine_name(&self) -> PluginSystemKind;

    /// Post initialization, once the context is available. This means you should be able to
    /// run anything here that could modify the context before the main editor is available.
    fn run_initialization_script(&self, _cx: &mut Context) {}

    /// Allow the engine to directly handle a keymap event. This is some of the tightest integration
    /// with the engine, directly intercepting any keymap events. By default, this just delegates to the
    /// editors default keybindings.
    #[inline(always)]
    fn handle_keymap_event(
        &self,
        _editor: &mut ui::EditorView,
        _mode: Mode,
        _cxt: &mut Context,
        _event: KeyEvent,
    ) -> Option<KeymapResult> {
        None
    }

    /// This attempts to call a function in the engine with the name `name` using the args `args`. The context
    /// is available here. Returns a bool indicating whether the function exists or not.
    #[inline(always)]
    fn call_function_if_global_exists(
        &self,
        _cx: &mut Context,
        _name: &str,
        _args: &[Cow<str>],
    ) -> bool {
        false
    }

    /// This is explicitly for calling a function via the typed command interface, e.g. `:vsplit`. The context here
    /// that is available is more limited than the context available in `call_function_if_global_exists`. This also
    /// gives the ability to handle in progress commands with `PromptEvent`.
    #[inline(always)]
    fn call_typed_command_if_global_exists<'a>(
        &self,
        _cx: &mut compositor::Context,
        _input: &'a str,
        _parts: &'a [&'a str],
        _event: PromptEvent,
    ) -> bool {
        false
    }

    /// Given an identifier, extract the documentation from the engine.
    #[inline(always)]
    fn get_doc_for_identifier(&self, _ident: &str) -> Option<String> {
        None
    }

    /// Fuzzy match the input against the fuzzy matcher, used for handling completions on typed commands
    #[inline(always)]
    fn available_commands<'a>(&self) -> Vec<Cow<'a, str>> {
        Vec::new()
    }

    /// Retrieve a theme for a given name
    #[inline(always)]
    fn load_theme(&self, _name: &str) -> Option<Theme> {
        None
    }

    /// Retrieve the list of themes that exist within the runtime
    #[inline(always)]
    fn themes(&self) -> Option<Vec<String>> {
        None
    }

    /// Fetch the language configuration as monitored by the plugin system.
    ///
    /// For now - this maintains backwards compatibility with the existing toml configuration,
    /// and as such the toml error is exposed here.
    #[inline(always)]
    fn load_language_configuration(&self) -> Option<Result<Configuration, toml::de::Error>> {
        None
    }
}

If you can implement this, the engine should be able to be embedded within Helix. On top of that, I believe what I have allows the coexistence of multiple scripting engines, with a built in priority for resolving commands / configurations / etc.

As a result, Steel here is entirely optional and also remains completely backwards compatible with the existing toml configuration. Steel is just another layer on the existing configuration chain, and as such will be applied last. This applies to both the config.toml and the languages.toml. Keybindings can be defined via Steel as well, and these can be buffer specific, language specific, or global. Themes can also be defined from Steel code and enabled, although this is not as rigorously tested and is a relatively recent addition. Otherwise, I have been using this as my daily driver to develop for the last few months.

I opted for a two tiered approach, centered around a handful of design ideas that I'd like feedback on:

The first, there is a init.scm and a helix.scm file - the helix.scm module is where you define any commands that you would like to use at all. Any function exposed via that module is eligible to be used as a typed command or via a keybinding. For example:

;; helix.scm

(provide shell)

;;@doc
;; Specialized shell - also be able to override the existing definition, if possible.
(define (shell cx . args)
  ;; Replace the % with the current file
  (define expanded (map (lambda (x) (if (equal? x "%") (current-path cx) x)) args))
  (helix.run-shell-command cx expanded helix.PromptEvent::Validate))

This would then make the command :shell available, and it will just replace the % with the current file. The documentation listed in the @doc doc comment will also pop up explaining what the command does:

image

Once the helix.scm module is require'd - then the init.scm file is run. One thing to note is that the helix.scm module does not have direct access to a running helix context. It must act entirely stateless of anything related to the helix context object. Running init.scm gives access to a helix object, currently defined as *helix.cx*. This is something I'm not sure I particularly love, as it makes async function calls a bit odd - I think it might make more sense to make the helix context just a global inside of a module. This would also save the hassle that every function exposed has to accept a cx parameter - this ends up with a great deal of boilerplate that I don't love. Consider the following:

;;@doc
;; Create a file under wherever we are
(define (create-file cx)
  (when (currently-in-labelled-buffer? cx FILE-TREE)
    (define currently-selected (list-ref *file-tree* (helix.static.get-current-line-number cx)))
    (define prompt
      (if (is-dir? currently-selected)
          (string-append "New file: " currently-selected "/")
          (string-append "New file: "
                         (trim-end-matches currently-selected (file-name currently-selected)))))

    (helix-prompt!
     cx
     prompt
     (lambda (cx result)
       (define file-name (string-append (trim-start-matches prompt "New file: ") result))
       (temporarily-switch-focus cx
                                 (lambda (cx)
                                   (helix.vsplit-new cx '() helix.PromptEvent::Validate)
                                   (helix.open cx (list file-name) helix.PromptEvent::Validate)
                                   (helix.write cx (list file-name) helix.PromptEvent::Validate)
                                   (helix.quit cx '() helix.PromptEvent::Validate)))

       (enqueue-thread-local-callback cx refresh-file-tree)))))

Every function call to helix built ins requires passing in the cx object - I think just having them be able to reference the global behind the scenes would make this a bit ergonomic. The integration with the helix runtime would make sure whether that variable actually points to a legal context, since we pass this in via reference, so it is only alive for the duration of the call to the engine.

Async functions

Steel has support for async functions, and has successfully been integrated with the tokio runtime used within helix, however it requires constructing manually the callback function yourself, rather than elegantly being able to use something like await. More to come on this, since the eventual design will depend on the decision to use a local context variable vs a global one.

Built in functions

The basic built in functions are first all of the function that are typed and static - i.e. everything here:

However, these functions don't return values so aren't particularly useful for anything but their side effects to the editor state. As a result, I've taken the liberty of defining functions as I've needed/wanted them. Some care will need to be decided what those functions actually exposed are.

Examples

Here are some examples of plugins that I have developed using Steel:

File tree

Source can be found here

filetree.webm

Recent file picker

Source can be found here

recent-files.webm

This persists your recent files between sessions.

Scheme indent

Since steel is a scheme, there is a relatively okay scheme indent mode that only applied on .scm files, which can be found here. The implementation requires a little love, but worked enough for me to use helix to write scheme code 😄

Terminal emulator

I did manage to whip up a terminal emulator, however paused the development of it while focusing on other things. When I get it back into working shape, I will post a video of it here. I am not sure what the status is with respect to a built in terminal emulator, but the one I got working did not attempt to do complete emulation, but rather just maintained a shell to interact with non-interactively (e.g. don't try to launch helix in it, you'll have a bad time 😄 )

Steel as a choice for a language

I understand that there is skepticism around something like Steel, however I have been working diligently on improving it. My current projects include shoring up the documentation, and working on an LSP for it to make development easier - but I will do that in parallel with maintaining this PR. If Steel is not chosen and a different language is picked, in theory the API I've exposed should do the trick at least with matching the implementation behavior that I've outlined here.

Pure rust plugins

As part of this, I spent some time trying to expose a C ABI from helix to do rust to rust plugins directly in helix without a scripting engine, with little success. Steel supports loading dylibs over a stable abi (will link to documentation once I've written it). I used this to develop the proof of concept terminal emulator. So, you might not be a huge fan of scheme code, but in theory you can write mostly Rust and use Steel as glue if you'd like - you would just be limited to the abi compatible types.

System compatibility

I develop off of Linux and Mac - but have not tested on windows. I have access to a windows system, and will get around to testing on that when the time comes.

@satvikpendem
Copy link

Just curious why Scheme instead of writing packages in Rust itself? Part of the reason I use Helix is because everything is in Rust and can be very performant. I worry if people start writing many popular plugins in Steel that we might get a situation like neovim which is slowed down by package loading and general performance speed, as it seems like Steel can be as slow as Python, even Lua which is neovim's plug-in language is faster than that.

@UltraBlackLinux
Copy link

UltraBlackLinux commented Jan 13, 2025

I don't see what needs to be particularly performant about helix, apart from opening large files in a timely manner, which happens regardless of the plugin system.

Compiled plugins are not cross compatible between all the architectures that helix can run on, and it's also much less approachable. rust is from what I've seen not an easy language to grasp, even if you have programming experience. Its syntax and concepts can be hard to understand.

A plugin system making use of scheme makes all of this much easier. The plugins are cross-compatible since the plugin-system is part of the editor itself, and the language is very simplistic (which doesn't have to be a bad thing, and emacs is proof for that. scheme is merely a lisp dialect)

@lcpichette
Copy link

@satvikpendem I'm sharing this explanation here for you, and for anyone else reviewing this PR, who may wonder why your comment was downvoted so quickly.

It’s generally expected—out of courtesy for everyone involved—that before asking a question, you take a moment to see if the answer is readily available. More than 50 people are subscribed to the updates on this PR, and many of them may be feeling fatigued by having to re-answer questions that have already been addressed multiple times.

In this case, the answer to your question can be found by searching for “plugin” in the Helix discussions, specifically looking at the top-rated thread (#3806). You can also Google it... there are Reddit threads and other sources that cover this topic thoroughly.

@satvikpendem
Copy link

@lcpichette I see, thank you, I didn't think to look in the discussions tab (truthfully I personally never use the discussions tab myself for my own projects and those I contribute to so that's probably why), I just searched the issues tab. I appreciate the response.

@nik-rev
Copy link
Contributor

nik-rev commented Jan 16, 2025

If at some point there's going to be an LSP for Steel as well as a formatter, would it make sense to ship those with the editor so that users don't even need to configure it?

@mattwparas
Copy link
Author

mattwparas commented Jan 16, 2025

If at some point there's going to be an LSP for Steel as well as a formatter, would it make sense to ship those with the editor so that users don't even need to configure it?

There is an LSP for steel already. For formatting, I don't have one specifically for steel, but have been using raco fmt, a formatter for racket.

@bbatsov
Copy link

bbatsov commented Feb 19, 2025

I came across the PR and I just wanted to say it looks really exciting. As a long-time Emacs user I know that a Lisp would be great for writing plugins (I love being able to tweak them, without the need to restart my editor) and I think once this is merged Helix will be extremely competitive with neovim and Emacs.

I hope this will land in the next major Helix release is some shape or form!

@tamadamas
Copy link

tamadamas commented Feb 21, 2025

I'm just average user, who wants an editor that just predictable work.

I've tried several editions of Emacs, Vim, NeoVim with no success because of plugins break and slow everything. And they want me learning new language just to use my editor(lisp is great anyway ^_^)
That's crazy.
VSCode, Atom, Intellij, Zed has won for me because of easy plugins and json/JavaScript/gui config. It's frustrating when NeoVim/Emacs just fails because something broken on update.
Raw Vim wins over Vim with packages when I need just edit my dotfiles the same as debian/fedora over arch.

The same case with MacOS/Iphone. They just work as expected.

Zed has failed recently and has been removed because of "doesn't start GPU issues...who cares how fast it is if it's broken".

It might be fallback/backup/supervisors(like Erlang) to be sure editor works in every moment and let me fix bugs and make new features in my code for business. I'm too old and tired to be tuning my editor again and again.

@nik-rev
Copy link
Contributor

nik-rev commented Feb 21, 2025

I've tried several editions of Emacs, Vim, NeoVim with no success because of plugins break and slow everything.

I understand your frustration with breakage in plugins. I myself switched to Helix exactly because of the issue you're describing. But:

  • Plugins will be optional, you don't need to use them if you don't want to.
  • Some features are infeasible to put into core. For example: Vim motions. Writing leetcode right in Helix. SQL query editor. Plugins give the flexibility for people to add features that are only wanted by a minority of people, or are too specific to include

And they want me learning new language just to use my editor

You likely won't need to learn the language unless you want to extend the editor with custom functionality. For simple configuration, the scheme config will be easy to use like the current TOML one

It might be fallback/backup/supervisors(like Erlang) to be sure editor works in every moment and let me fix bugs and make new features in my code for business. I'm too old and tired to be tuning my editor again and again.

Since Helix has a lot more built-in than Neovim when plugins eventually arrive the average Helix user will likely use significantly less plugins than the average Neovimmer. Less plugins = less chance things will be set on fire.

When I was rolling Neovim I had somewhat like 60 plugins. Each of those plugins Helix replaces out of the box for me

@mattwparas
Copy link
Author

I've seen this comment many times, about how plugins introduce a risk of breaking everything. I'd hope there is enough faith in the process that I wouldn't build something that would run into this drastic failure mode.

Default helix has great functionality, if you have no plugins, it behaves as it has always behaved. The architecture I've chosen to operate should give the ability to load one plugin at a time. The failure of one plugin to load should not affect another plugin. Of course, a plugin might become stale or outdated relative to helix / the host environment, and run into issues post upgrade - this happens with VSCode or IntelliJ or any other editor as well really. If that is the case, the one plugin should fail to load, but the rest will continue on as usual assuming all is good.

I've also taken care for things such as interrupting any currently running commands; so lets say you just write an infinite loop as a command - you should be able to interrupt it and regain control of the UI.

In the worst case, lets say I do break every plugin, which happens while I'm developing this - I still can use the base level editor that works great. LSP, syntax highlighting, all the built in commands - everything would still behave as usual.

@tamadamas
Copy link

I've tried several editions of Emacs, Vim, NeoVim with no success because of plugins break and slow everything.

I understand your frustration with breakage in plugins. I myself switched to Helix exactly because of the issue you're describing. But:

  • Plugins will be optional, you don't need to use them if you don't want to.
  • Some features are infeasible to put into core. For example: Vim motions. Writing leetcode right in Helix. SQL query editor. Plugins give the flexibility for people to add features that are only wanted by a minority of people, or are too specific to include

And they want me learning new language just to use my editor

You likely won't need to learn the language unless you want to extend the editor with custom functionality. For simple configuration, the scheme config will be easy to use like the current TOML one

It might be fallback/backup/supervisors(like Erlang) to be sure editor works in every moment and let me fix bugs and make new features in my code for business. I'm too old and tired to be tuning my editor again and again.

Since Helix has a lot more built-in than Neovim when plugins eventually arrive the average Helix user will likely use significantly less plugins than the average Neovimmer. Less plugins = less chance things will be set on fire.

When I was rolling Neovim I had somewhat like 60 plugins. Each of those plugins Helix replaces out of the box for me

I've tried "hx --tutor" this week and I'm impressed by how fast and easy it works. "Select and do action" is the most understandable and common pattern in my daily work I do in every editor. I just need to forget some patterns from vim like "dd" and do select first. Probably I will tune some keys in the helix.

@tamadamas
Copy link

I've seen this comment many times, about how plugins introduce a risk of breaking everything. I'd hope there is enough faith in the process that I wouldn't build something that would run into this drastic failure mode.

Default helix has great functionality, if you have no plugins, it behaves as it has always behaved. The architecture I've chosen to operate should give the ability to load one plugin at a time. The failure of one plugin to load should not affect another plugin. Of course, a plugin might become stale or outdated relative to helix / the host environment, and run into issues post upgrade - this happens with VSCode or IntelliJ or any other editor as well really. If that is the case, the one plugin should fail to load, but the rest will continue on as usual assuming all is good.

I've also taken care for things such as interrupting any currently running commands; so lets say you just write an infinite loop as a command - you should be able to interrupt it and regain control of the UI.

In the worst case, lets say I do break every plugin, which happens while I'm developing this - I still can use the base level editor that works great. LSP, syntax highlighting, all the built in commands - everything would still behave as usual.

That's what I say about "like Erlang supervisors". If plugin fails, editor just disable it and ask user one time. Instead of Neovim behavior (it annoys with repeated notification of some lsp not installed or failed or plugin updated/not updated.

I would prefer to enter some "Zen mode" that silence everything else that not my code even lsp. I usually do tests, linting, other stuff from os terminal and not care about embedded git, terminal, cool things of IDE (that's to you, VSCode, you're too much within your ego 😅)

@ronisbr
Copy link

ronisbr commented Feb 23, 2025

Some features are infeasible to put into core. For example: Vim motions. Writing leetcode right in Helix. SQL query editor. Plugins give the flexibility for people to add features that are only wanted by a minority of people, or are too specific to include

I couldn't agree more! The plugin system will offer numerous possibilities for customizing the editor, which are currently beyond our reach. In my opinion, it will facilitate the adoption of the editor by a wider audience.

For instance, I spent approximately two weeks trying to use Helix, but it significantly reduced my productivity compared to Neovim. The primary issue was Copilot and spellchecking. Additionally, there were numerous minor features in Vim that Helix lacked, making it inconvenient. For example, in Helix, you can’t center text using the :center command, which is a simple task in Vim. In Helix, I cannot reindent a code using tree-sitter (only the LSP), which is very handy if you are pasting a lot of code. Furthermore, creating macros to transform this text:

#
Section

into this one:

############################################################################################
#                                         Section                                          #
############################################################################################

is impossible with Helix (the number of # must depend on the selected textwidth parameter).

Most of you probally will never need to do that, but I do! A lot!

Hence, if Helix has a plugin system now, I might be able to create some code to add this small functionality and continue to work. If a plugin system does not happen, I am pretty sure Helix devs will never put such a feature into core just for me :D

@nik-rev
Copy link
Contributor

nik-rev commented Feb 24, 2025

For example, in Helix, you can’t center text using the :center command, which is a simple task in Vim

you're not the first to suggest align text in Helix. I think this is generally useful to have in a text editor, so I've opened a PR for it:

align-text-helix.mp4

For instance, I spent approximately two weeks trying to use Helix, but it significantly reduced my productivity compared to Neovim. The primary issue was Copilot and spellchecking.

Since Helix doesn't have a built-in spellchecker, I am using the typos-lsp in markdown files and it works quite well!

image

here's my config:

[[language]]
name = "markdown"
language-servers = ["typos-lsp"]

[language-server.typos-lsp]
command = "typos-lsp"

@ronisbr
Copy link

ronisbr commented Feb 24, 2025

Since Helix doesn't have a built-in spellchecker, I am using the typos-lsp in markdown files and it works quite well!

Thanks for the suggestions! I tried typos-lsp but unfortunately it supports only English. I have a lot use cases where I need to spell check against Portuguese and English.

@eikenb
Copy link

eikenb commented Feb 25, 2025

@ronisbr .. another option for a spell checker is vale and vale-lsp, I think it supports Portuguese. The wiki has an entry for it.

https://github.com/helix-editor/helix/wiki/Language-Server-Configurations#vale-ls

@lianghu
Copy link

lianghu commented Feb 26, 2025

Any plan to land this plugin system? any alternative method to support GNU Global Tag System (gtags) in Helix? clangd is slow for large codebase like Linux kernel.

@kirawi
Copy link
Member

kirawi commented Feb 27, 2025

You can write an LSP to support gtags. Someone did so for ctags: https://github.com/yowu/ctags_ls & https://github.com/netmute/ctags-lsp

Comment on lines 169 to 174
<<<<<<< HEAD
code-gen: Generate files associated with steel
steel: Install steel
=======
theme-check: Check that theme files in runtime/themes are valid.
>>>>>>> origin
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to note that some merge-conflicts sneaked in here (as well for commented-out code in helix-term/src/commands/typed.rs)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
S-experimental Status: Ongoing experiment that does not require reviewing and won't be merged in its current state. S-waiting-on-pr Status: This is waiting on another PR to be merged first
Projects
None yet
Development

Successfully merging this pull request may close these issues.