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 146 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.

@aliresool621
Copy link

Can i just point out janet, the language was written by the same guy who made fennel(compiles to regular lua but uses s-expression and few more things), hear is an introduction

It also has a rust bindings

@fominok
Copy link

fominok commented Nov 28, 2024

Can i just point out janet, the language was written by the same guy who made fennel(compiles to regular lua but uses s-expression and few more things), hear is an introduction

It also has a rust bindings

As far as I understand those bindings suffer from the same problem as bindings to Guile a while ago: languages internally use setjmp/longjmp and that can skip destructors in Rust

@aliresool621
Copy link

As far as I understand those bindings suffer from the same problem as bindings to Guile a while ago: languages internally use setjmp/longjmp and that can skip destructors in Rust

I didn't know that, sorry...

@merisbahti
Copy link
Contributor

I'm trying to write a save-without-formatting-command and here's what I came up with:

(require (prefix-in helix. "helix/commands.scm"))
(require (prefix-in config. "helix/configuration.scm"))
(require (prefix-in term. "term.scm"))

(provide save-without-formatting)

(define (save-without-formatting)
  (config.auto-format #f)
  (helix.write)
  (config.auto-format #t))

But the problem is, I want it to preserve whatever option auto-format was before running the command, so I figured this would work:

(define oldValue (helix.get-option "auto-format")

But helix.get-option "auto-format" actually returns #void (but strangely logs a true/false value to the line under the status line)

any idea on how to get the value of a config option in steel?

@mattwparas
Copy link
Author

I'm trying to write a save-without-formatting-command and here's what I came up with:

(require (prefix-in helix. "helix/commands.scm"))
(require (prefix-in config. "helix/configuration.scm"))
(require (prefix-in term. "term.scm"))

(provide save-without-formatting)

(define (save-without-formatting)
  (config.auto-format #f)
  (helix.write)
  (config.auto-format #t))

But the problem is, I want it to preserve whatever option auto-format was before running the command, so I figured this would work:

(define oldValue (helix.get-option "auto-format")

But helix.get-option "auto-format" actually returns #void (but strangely logs a true/false value to the line under the status line)

any idea on how to get the value of a config option in steel?

So the reason for that would be that this sets the value to the status line, but it doesn't actually return anything:

// Fetch the current value of a config option and output as status.
fn get_option(
    cx: &mut compositor::Context,
    args: &[Cow<str>],
    event: PromptEvent,
) -> anyhow::Result<()> {
    if event != PromptEvent::Validate {
        return Ok(());
    }

    if args.len() != 1 {
        anyhow::bail!("Bad arguments. Usage: `:get key`");
    }

    let key = &args[0].to_lowercase();
    let key_error = || anyhow::anyhow!("Unknown key `{}`", key);

    let config = serde_json::json!(cx.editor.config().deref());
    let pointer = format!("/{}", key.replace('.', "/"));
    let value = config.pointer(&pointer).ok_or_else(key_error)?;

    cx.editor.set_status(value.to_string());
    Ok(())
}

There would just need to be a version of this function that actually returns the value. I can push something up in a few hours.

@merisbahti
Copy link
Contributor

Think I found a bug, because I couldn't call the function in steel.

mattwparas#13

@merisbahti
Copy link
Contributor

FYI, if there's any other plugin authors out there, here's a pretty useful function to re-load the current file into the steel repl:

(require (prefix-in helix.editor. "helix/editor.scm"))
(require "cogs/package.scm")

(provide load-current-steel-file-into-helix)
;;@doc
;; reloads the path of the current buffer, pretty useful for developing plugins for helix
(define (load-current-steel-file-into-helix)
  (define current-path
    (let* ([focus (helix.editor.editor-focus)]
           [focus-doc-id (helix.editor.editor->doc-id focus)])
      (helix.editor.editor-document->path focus-doc-id)))
  (load-package current-path))

Comment on lines 712 to 719
builtin_configuration_module.push_str(&format!(
r#"
(provide get-config-option-value)
(define (get-config-option-value arg)
(helix.get-config-option-value *helix.cx*))
"#,
));

Choose a reason for hiding this comment

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

Hi @mattwparas, thanks for your work on this PR! 🎉

I noticed that in the call to helix.get-config-option-value in the code-gen, the argument arg is unused. From looking at the surrounding context, it seems like this argument is necessary to ensure being passed to the helix.get-config-option-value.

Would it make sense to pass the argument like this: (helix.get-config-option-value *helix.cx* arg)? Let me know if I can help further with this. Thanks again for the great work!

@NikitaRevenco
Copy link
Contributor

NikitaRevenco commented Dec 6, 2024

I have an idea, for people who build plugins, suffix your plugin names with ".hx"

basically all neovim plugins end in "plugin.nvim", so let's do something like that and have "plugin.hx" :D

@Omnikar
Copy link
Contributor

Omnikar commented Dec 6, 2024

I have an idea, for people who build plugins, suffix your plugin names with ".hx"

basically all neovim plugins end in "plugin.nvim", so let's do something like that and have "plugin.hx" :D

If I recall that is the file extension used by Haxe code files.

@NikitaRevenco
Copy link
Contributor

I have an idea, for people who build plugins, suffix your plugin names with ".hx"

basically all neovim plugins end in "plugin.nvim", so let's do something like that and have "plugin.hx" :D

If I recall that is the file extension used by Haxe code files.

plugin.hx is just the plugin's name, it isn't used for a file extension

@RGBCube
Copy link

RGBCube commented Dec 6, 2024

This thread is really long, so I haven't checked the history. Who's working on plugins?

Maybe we could make an awesome-helix repository that links Helix distributions & plugins. Would also be nice to have it on the website

@NikitaRevenco
Copy link
Contributor

This thread is really long, so I haven't checked the history. Who's working on plugins?

Maybe we could make an awesome-helix repository that links Helix distributions & plugins. Would also be nice to have it on the website

I think people are waiting until release to start working on plugins, since there may be breaking changes

@rockygolucky
Copy link

Maybe we could make an awesome-helix repository that links Helix distributions & plugins. Would also be nice to have it on the website

I would absolutely adore that.

@rockygolucky
Copy link

Not to put the horse before the cart too much, but I do wonder about the distribution mechanism for packages.

Other editors have entire package managers just for plugins. I can’t say I miss the days of deciding which Neovim plugin manager to use.

It would be cool if there was a sensible default.

@mattwparas
Copy link
Author

Not to put the horse before the cart too much, but I do wonder about the distribution mechanism for packages.

Other editors have entire package managers just for plugins. I can’t say I miss the days of deciding which Neovim plugin manager to use.

It would be cool if there was a sensible default.

I have a functioning package manager working on a branch for steel, which will work for helix as well. Trying to dog food it a bit and iron out some details before releasing. At the moment the distribution mechanism is just git urls, which will probably work to start.

@RGBCube
Copy link

RGBCube commented Dec 7, 2024

I think it shouldn't be bundled by default, as people might want to use other package managers. I'm going to use home-manager for example and a builtin :plugin-install would immediately break as the directory will be immutable.

But a recommended plugin would be great, for people who want the default experience

@rockygolucky
Copy link

I'm going to use home-manager for example and a builtin :plugin-install would immediately break as the directory will be immutable.

That’s a good point. I would use home-manager too.

@zetashift
Copy link
Contributor

I think it shouldn't be bundled by default, as people might want to use other package managers. I'm going to use home-manager for example and a builtin :plugin-install would immediately break as the directory will be immutable.

I think it's fine if it's bundled by default, but not enabled by default(maybe you meant it that way, and I misread).
I'm also a Nix/home-manager user, and I don't see the situation happen where a you'd run :plugin-install manually? Wouldn't that be more like :sh home-manager switch.

Besides there are plenty of things in home-manager that do ship with a package manager, like Node or even Emacs comes with package.el(just not 'enabled' by default).

@eikenb
Copy link

eikenb commented Dec 7, 2024

As a happy (lurking) user I'd like to throw in vote on the package manager discussion... IMO it should be installed and enabled by default but easy to disable. I don't think standalone environment managers like home-manager are that pervasive and most users would benefit from having package management be a solved issue that they didn't have to consider. Thanks.

@mattwparas
Copy link
Author

mattwparas commented Dec 7, 2024

Including modules in steel is really quite simple; just tell steel where to look for them. Given that flexibility (for better or for worse), it means that no one package management solution will be the only way to do things. I'm hoping to provide a very sensible and functional default, but if you'd like to use another solution you should be more than welcome to.

I myself don't use nix, so rest assured we'll have something that works for non nix users

@Dietr1ch
Copy link

Dietr1ch commented Dec 8, 2024

I guess that straight.el's comparison to use-package might come useful when discussing what's needed from the plugin management system, but starting from reading a single directory configured at startup seems flexible enough for plugin systems to proliferate and explore a common plugin definition, config, and versioning system.

Being able to configure that single directory is important, on Emacs trying out different configs or patches/fixes to existing ones and such was painful enough that chemacs and chemacs2 came out to help define what configuration and set of plugins should be used.

@noahfraiture
Copy link

Having a default package manager is one of the greatest feature in new language in my opinion, it standardize and regulate stuffs. It's good to have different possibility but it should be an option and you shouldn't have to make a choice in the first place to try stuff. Having a lot of possibility when it comes to plugin management in neovim is, in my opinion, a weakness concerning beginner as every plugin will have different instruction of installation and it's a real pain when you start your nvim journey

@noor-tg
Copy link

noor-tg commented Dec 8, 2024

Will steel allow something like this ?

a-h/templ#387 (comment)

Quick and di

For any LazyVim users who could not get catgoose's plugin to work, here's a solution that works for me. Put the following in lua/plugins/lsp.lua:

return {
  "neovim/nvim-lspconfig",
  opts = function(args)
    local go_to_definition = function()
      if vim.bo.filetype == "go" then
        vim.lsp.buf.definition({
          on_list = function(options)
            if options == nil or options.items == nil or #options.items == 0 then
              return
            end

            local targetFile = options.items[1].filename
            local prefix = string.match(targetFile, "(.-)_templ%.go$")

            if prefix then
              local function_name = vim.fn.expand("<cword>")
              options.items[1].filename = prefix .. ".templ"

              vim.fn.setqflist({}, " ", options)
              vim.api.nvim_command("cfirst")

              vim.api.nvim_command("silent! /templ " .. function_name)
            else
              vim.lsp.buf.definition()
            end
          end,
        })
      else
        vim.lsp.buf.definition()
      end
    end
    local function go_goto_def()
      if vim.bo.filetype == "go" then
        return go_to_definition()
      else
        return vim.lsp.buf.definition()
      end
    end
    local keys = require("lazyvim.plugins.lsp.keymaps").get()
    keys[#keys + 1] = { "gd", go_goto_def }
  end,
}

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.